wails/v3/pkg/application/screenmanager_test.go
Lea Anthony 66ad93d9d5
feat: Complete App API restructuring with organized manager pattern (#4359)
* Initial refactor

* More refactoring of API

* Update gitignore

* Potential fix for code scanning alert no. 134: Incorrect conversion between integer types

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Update v3/internal/generator/testcases/variable_single_from_function/main.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update v3/pkg/application/context_menu_manager.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update v3/pkg/application/event_manager.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update v3/pkg/application/context_menu_manager.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix build issues

* Fix build issues

* Address CodeRabbitAI review feedback: fix goroutines, error handling, and resource management

- Fix infinite goroutines with proper context cancellation and ticker cleanup
- Add error handling to window creation calls
- Prevent unbounded slice growth in gin-service and screen examples
- Use graceful shutdown patterns with app.Context().Done()

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix manager API refactor issues and complete all v3 example builds

- Fixed slices import missing in event_manager.go
- Changed contextMenusLock from sync.Mutex to sync.RWMutex for RLock/RUnlock compatibility
- Updated all globalApplication calls to use new manager pattern (Windows.Current, Events.OnApplicationEvent, etc.)
- Fixed Events.Emit vs Events.EmitEvent method signature mismatch
- Corrected NewWithOptions calls (returns 1 value, not 2) in examples
- Added comprehensive .gitignore patterns for all v3 example binaries
- All 34 v3 examples now build successfully

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix Linux platform manager API calls

- Updated events_common_linux.go: OnApplicationEvent → Events.OnApplicationEvent
- Updated application_linux.go: OnApplicationEvent → Events.OnApplicationEvent
- Ensures Linux builds work with new manager pattern

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix remaining NewWithOptions assignment errors in examples

- Fixed badge/main.go: removed assignment mismatch and unused variable
- Fixed badge-custom/main.go: removed assignment mismatch and variable reuse
- Fixed file-association/main.go: removed assignment mismatch and unused variable
- All examples now use correct single-value assignment for NewWithOptions()

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Implement multi-architecture Docker compilation system for Linux builds

### New Features
- **Multi-Architecture Support**: Native ARM64 and x86_64 Docker compilation
- **Auto-Detection**: Automatic architecture detection in Taskfile tasks
- **Complete Cross-Platform Testing**: 129 builds (43 examples × 3 platforms)

### Docker Infrastructure
- `Dockerfile.linux-arm64`: Ubuntu 24.04 ARM64 native compilation
- `Dockerfile.linux-x86_64`: Ubuntu 24.04 x86_64 native compilation
- Architecture-specific build scripts with colored output and error handling
- Native compilation eliminates CGO cross-compilation issues

### Task System Updates
- **New Tasks**: `test:examples:all` for complete cross-platform testing
- **Architecture-Specific**: `test:examples:linux:docker:arm64/x86_64`
- **Auto-Detection**: `test:example:linux:docker` detects host architecture
- **Clear Parameter Usage**: Documented when DIR parameter is/isn't needed

### Build Artifacts
- Architecture-specific naming: `testbuild-{example}-linux-{arch}`
- ARM64: `testbuild-badge-linux-arm64`
- x86_64: `testbuild-badge-linux-x86_64`

### Documentation
- Complete TESTING.md overhaul with multi-architecture support
- Clear command reference distinguishing single vs all example builds
- Updated build performance estimates (10-15 minutes for 129 builds)
- Comprehensive troubleshooting and usage examples

### Infrastructure Cleanup
- Removed deprecated `Dockerfile.linux-proper`
- Updated .gitignore for new build artifact patterns
- Streamlined Taskfile with architecture-aware Linux tasks

**Status**: Production-ready multi-architecture Docker compilation system

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix CLI appimage testfiles API migration and add to testing system

### API Migration Fixes
- **Event Registration**: Updated `app.OnApplicationEvent()` → `app.Events.OnApplicationEvent()`
- **Window Manager**: Updated `app.CurrentWindow()` → `app.Windows.Current()`
- **Window Creation**: Updated `app.NewWebviewWindowWithOptions()` → `app.Windows.NewWithOptions()`
- **Menu Manager**: Updated `app.SetMenu()` → `app.Menus.SetApplicationMenu()`
- **Screen API**: Updated `app.GetPrimaryScreen()/GetScreens()` → `app.Screens.GetPrimary()/GetAll()`

### Testing System Enhancement
- **New Task**: `task test:cli` for CLI-related code compilation testing
- **Integration**: Added CLI testing to `task test:examples` and `task test:examples:all`
- **Documentation**: Updated TESTING.md to include CLI code testing

### Files Fixed
- `internal/commands/appimage_testfiles/main.go`: Complete API migration
- `Taskfile.yaml`: Added CLI testing tasks and integration
- `TESTING.md`: Updated documentation to reflect CLI testing

This ensures CLI code API migrations are caught by our testing system and prevents
future build breakages in CLI components.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Enhance testing infrastructure and fix API migration issues across v3 codebase

This commit introduces comprehensive testing infrastructure to catch API migration issues and fixes all remaining compatibility problems:

## Enhanced Testing Infrastructure
- Added `test:cli:all` task to validate CLI components compilation
- Added `test:generator` task to test code generator test cases
- Added `test:infrastructure` task for comprehensive infrastructure testing
- Updated `test:examples` to include CLI testing automatically

## API Migration Fixes
- Fixed manager-based API calls in window visibility test (app.Windows.NewWithOptions)
- Fixed manager-based API calls in screen manager tests (sm.GetAll, sm.GetPrimary)
- Fixed event registration API in 6 service test files (app.Events.OnApplicationEvent)
- Updated menu API calls (app.Menus.SetApplicationMenu)

## Cross-Platform Validation
- All 43 examples compile successfully on Darwin
- CLI components compile without errors
- Generator test cases validate correctly
- Application package tests pass compilation

The enhanced testing system integrates with existing GitHub Actions CI/CD and will automatically catch future API migration issues, ensuring ecosystem stability as the v3 API evolves.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix template API migration issues and add comprehensive template testing

This commit resolves the GitHub Actions template generation failures by fixing API migration issues in the template source files and adding comprehensive template testing to the infrastructure.

## Template API Fixes
- Fixed `app.NewWebviewWindowWithOptions()` → `app.Windows.NewWithOptions()` in main.go.tmpl
- Fixed `app.EmitEvent()` → `app.Events.Emit()` in main.go.tmpl
- Updated the _common template used by all framework templates (lit, react, vue, etc.)

## Enhanced Testing Infrastructure
- Added `test:templates` task to validate template generation and compilation
- Tests lit and react template generation with API migration validation
- Integrated template testing into `test:infrastructure` task
- Templates now tested alongside CLI components, generator, and application tests

## GitHub Actions Compatibility
- Resolves template generation failures in CI/CD pipeline
- Ensures all generated projects use correct manager-based API calls
- Maintains template consistency across all supported frameworks

The template testing validates that generated projects compile successfully with the new manager-based API pattern, preventing future template generation failures in GitHub Actions.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Reorganize Docker testing files into test directory

- Move Dockerfiles from root to test/docker/
- Update all Taskfile.yaml Docker build paths
- Update TESTING.md documentation
- Maintain full backward compatibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Address CodeRabbit review: improve resource management and API patterns

- Store event handler cleanup functions for proper resource management
- Fix goroutine management with context-aware cancellation patterns
- Add documentation for error handling best practices
- Improve API consistency across examples

Examples updated:
- plain: Fixed event handlers and goroutine lifecycle
- badge: Added cleanup function storage
- gin-example: Proper event handler management
- gin-service: Service lifecycle cleanup

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Address CodeRabbit nitpicks: optimize Docker images and docs

Docker Optimizations:
- Add --no-install-recommends and apt-get clean for smaller images
- Add SHA256 checksum verification for Go downloads
- Remove unnecessary GO111MODULE env (default in Go 1.16+)
- Add hadolint ignore for here-doc blocks

Build Enhancements:
- Add --pull flag to Docker builds for fresh base images
- Improve build reliability and consistency

Documentation Fixes:
- Add proper language tags to code blocks (bash, text)
- Fix heading formatting and remove trailing punctuation
- Improve syntax highlighting and readability

Files updated:
- test/docker/Dockerfile.linux-arm64
- test/docker/Dockerfile.linux-x86_64
- Taskfile.yaml
- TESTING.md

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update changelog: document Manager API refactoring and improvements

Breaking Changes:
- Manager API Refactoring: Complete reorganization from flat structure
  to organized managers (Windows, Events, Dialogs, etc.)
- Comprehensive API migration guide with all method mappings
- References PR #4359 for full context

Added:
- Organized testing infrastructure in test/docker/ directory
- Improved resource management patterns in examples
- Enhanced Docker images with optimizations and security

This documents the major architectural changes and improvements
made to the Wails v3 API and development infrastructure.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Support cross-platform testing

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-21 19:51:14 +10:00

716 lines
24 KiB
Go

package application_test
import (
"fmt"
"math"
"slices"
"strconv"
"testing"
"github.com/matryer/is"
"github.com/wailsapp/wails/v3/pkg/application"
)
type ScreenDef struct {
id int
w, h int
s float32
parent ScreenDefParent
name string
}
type ScreenDefParent struct {
id int
align string
offset int
}
type ScreensLayout struct {
name string
screens []ScreenDef
}
type ParsedLayout struct {
name string
screens []*application.Screen
}
func exampleLayouts() []ParsedLayout {
layouts := [][]ScreensLayout{
{
// Normal examples (demonstrate real life scenarios)
{
name: "Single 4k monitor",
screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
},
},
{
name: "Two monitors",
screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
},
},
{
name: "Two monitors (2)",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1, name: `23" FHD 96DPI`},
{id: 2, w: 1920, h: 1080, s: 1.25, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI (125%)`},
},
},
{
name: "Three monitors",
screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
{id: 3, w: 1920, h: 1080, s: 1.25, parent: ScreenDefParent{id: 1, align: "l", offset: 0}, name: `23" FHD 96DPI (125%)`},
},
},
{
name: "Four monitors",
screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
{id: 3, w: 1920, h: 1080, s: 1.25, parent: ScreenDefParent{id: 2, align: "b", offset: 0}, name: `23" FHD 96DPI (125%)`},
{id: 4, w: 1080, h: 1920, s: 1, parent: ScreenDefParent{id: 1, align: "l", offset: 0}, name: `23" FHD (90deg)`},
},
},
},
{
// Test cases examples (demonstrate the algorithm basics)
{
name: "Child scaled, Start offset",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: 600}, name: "Child"},
},
},
{
name: "Child scaled, End offset",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: -600}, name: "Child"},
},
},
{
name: "Parent scaled, Start offset percent",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 600}, name: "Child"},
},
},
{
name: "Parent scaled, End offset percent",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: -600}, name: "Child"},
},
},
{
name: "Parent scaled, Start align",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1100, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: "Child"},
},
},
{
name: "Parent scaled, End align",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: "Child"},
},
},
{
name: "Parent scaled, in-between",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1500, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: -250}, name: "Child"},
},
},
},
{
// Edge cases examples
{
name: "Parent order (5 is parent of 4)",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 600, s: 1.25, parent: ScreenDefParent{id: 1, align: "r", offset: -200}},
{id: 3, w: 800, h: 800, s: 1.25, parent: ScreenDefParent{id: 2, align: "b", offset: 0}},
{id: 4, w: 800, h: 1080, s: 1.5, parent: ScreenDefParent{id: 2, align: "re", offset: 100}},
{id: 5, w: 600, h: 600, s: 1, parent: ScreenDefParent{id: 3, align: "r", offset: 100}},
},
},
{
name: "de-intersection reparent",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1680, h: 1050, s: 1.25, parent: ScreenDefParent{id: 1, align: "r", offset: 10}},
{id: 3, w: 1440, h: 900, s: 1.5, parent: ScreenDefParent{id: 1, align: "le", offset: 150}},
{id: 4, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 3, align: "bc", offset: -200}},
{id: 5, w: 1024, h: 768, s: 1.25, parent: ScreenDefParent{id: 4, align: "r", offset: 400}},
},
},
{
name: "de-intersection (unattached child)",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 768, s: 1.5, parent: ScreenDefParent{id: 1, align: "le", offset: 10}},
{id: 3, w: 1024, h: 768, s: 1.25, parent: ScreenDefParent{id: 2, align: "b", offset: 100}},
{id: 4, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 3, align: "r", offset: 500}},
},
},
{
name: "Multiple de-intersection",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 1, align: "be", offset: 0}},
{id: 3, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 2, align: "b", offset: 300}},
{id: 4, w: 1024, h: 768, s: 1.5, parent: ScreenDefParent{id: 2, align: "le", offset: 100}},
{id: 5, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 4, align: "be", offset: 100}},
},
},
{
name: "Multiple de-intersection (left-side)",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 1, align: "le", offset: 0}},
{id: 3, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 2, align: "b", offset: 300}},
{id: 4, w: 1024, h: 768, s: 1.5, parent: ScreenDefParent{id: 2, align: "le", offset: 100}},
{id: 5, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 4, align: "be", offset: 100}},
},
},
{
name: "Parent de-intersection child offset",
screens: []ScreenDef{
{id: 1, w: 1600, h: 1600, s: 1.5},
{id: 2, w: 800, h: 800, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}},
{id: 3, w: 800, h: 800, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 800}},
{id: 4, w: 800, h: 1600, s: 1, parent: ScreenDefParent{id: 2, align: "r", offset: 0}},
},
},
},
}
parsedLayouts := []ParsedLayout{}
for _, section := range layouts {
for _, layout := range section {
parsedLayouts = append(parsedLayouts, parseLayout(layout))
}
}
return parsedLayouts
}
// Parse screens layout from easy-to-define ScreenDef for testing to actual Screens layout
func parseLayout(layout ScreensLayout) ParsedLayout {
screens := []*application.Screen{}
for _, screen := range layout.screens {
var x, y int
w := screen.w
h := screen.h
if screen.parent.id > 0 {
idx := slices.IndexFunc(screens, func(s *application.Screen) bool { return s.ID == strconv.Itoa(screen.parent.id) })
parent := screens[idx].Bounds
offset := screen.parent.offset
align := screen.parent.align
align2 := ""
if len(align) == 2 {
align2 = string(align[1])
align = string(align[0])
}
x = parent.X
y = parent.Y
// t: top, b: bottom, l: left, r: right, e: edge, c: corner
if align == "t" || align == "b" {
x += offset
if align2 == "e" || align2 == "c" {
x += parent.Width
}
if align2 == "e" {
x -= w
}
if align == "t" {
y -= h
} else {
y += parent.Height
}
} else {
y += offset
if align2 == "e" || align2 == "c" {
y += parent.Height
}
if align2 == "e" {
y -= h
}
if align == "l" {
x -= w
} else {
x += parent.Width
}
}
}
name := screen.name
if name == "" {
name = "Display" + strconv.Itoa(screen.id)
}
screens = append(screens, &application.Screen{
ID: strconv.Itoa(screen.id),
Name: name,
ScaleFactor: float32(math.Round(float64(screen.s)*100) / 100),
X: x,
Y: y,
Size: application.Size{Width: w, Height: h},
Bounds: application.Rect{X: x, Y: y, Width: w, Height: h},
PhysicalBounds: application.Rect{X: x, Y: y, Width: w, Height: h},
WorkArea: application.Rect{X: x, Y: y, Width: w, Height: h - int(40*screen.s)},
PhysicalWorkArea: application.Rect{X: x, Y: y, Width: w, Height: h - int(40*screen.s)},
IsPrimary: screen.id == 1,
Rotation: 0,
})
}
return ParsedLayout{
name: layout.name,
screens: screens,
}
}
func matchRects(r1, r2 application.Rect) error {
threshold := 1.0
if math.Abs(float64(r1.X-r2.X)) > threshold ||
math.Abs(float64(r1.Y-r2.Y)) > threshold ||
math.Abs(float64(r1.Width-r2.Width)) > threshold ||
math.Abs(float64(r1.Height-r2.Height)) > threshold {
return fmt.Errorf("%v != %v", r1, r2)
}
return nil
}
// Test screens layout (DPI transformation)
func TestScreenManager_ScreensLayout(t *testing.T) {
sm := application.ScreenManager{}
t.Run("Child scaled", func(t *testing.T) {
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: 600}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
screens := sm.GetAll()
is.Equal(len(screens), 2) // 2 screens
is.Equal(screens[0].PhysicalBounds, application.Rect{X: 0, Y: 0, Width: 1200, Height: 1200}) // Parent physical bounds
is.Equal(screens[0].Bounds, screens[0].PhysicalBounds) // Parent no scaling
is.Equal(screens[1].PhysicalBounds, application.Rect{X: 1200, Y: 600, Width: 1200, Height: 1200}) // Child physical bounds
is.Equal(screens[1].Bounds, application.Rect{X: 1200, Y: 600, Width: 800, Height: 800}) // Child DIP bounds
})
t.Run("Parent scaled", func(t *testing.T) {
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 600}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
screens := sm.GetAll()
is.Equal(len(screens), 2) // 2 screens
is.Equal(screens[0].PhysicalBounds, application.Rect{X: 0, Y: 0, Width: 1200, Height: 1200}) // Parent physical bounds
is.Equal(screens[0].Bounds, application.Rect{X: 0, Y: 0, Width: 800, Height: 800}) // Parent DIP bounds
is.Equal(screens[1].PhysicalBounds, application.Rect{X: 1200, Y: 600, Width: 1200, Height: 1200}) // Child physical bounds
is.Equal(screens[1].Bounds, application.Rect{X: 800, Y: 400, Width: 1200, Height: 1200}) // Child DIP bounds
})
}
// Test basic transformation between physical and DIP coordinates
func TestScreenManager_BasicTranformation(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: 600}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
pt := application.Point{X: 100, Y: 100}
is.Equal(sm.DipToPhysicalPoint(pt), pt) // DipToPhysicalPoint screen1
is.Equal(sm.PhysicalToDipPoint(pt), pt) // PhysicalToDipPoint screen1
ptDip := application.Point{X: 1300, Y: 700}
ptPhysical := application.Point{X: 1350, Y: 750}
is.Equal(sm.DipToPhysicalPoint(ptDip), ptPhysical) // DipToPhysicalPoint screen2
is.Equal(sm.PhysicalToDipPoint(ptPhysical), ptDip) // PhysicalToDipPoint screen2
rect := application.Rect{X: 100, Y: 100, Width: 200, Height: 300}
is.Equal(sm.DipToPhysicalRect(rect), rect) // DipToPhysicalRect screen1
is.Equal(sm.PhysicalToDipRect(rect), rect) // DipToPhysicalRect screen1
rectDip := application.Rect{X: 1300, Y: 700, Width: 200, Height: 300}
rectPhysical := application.Rect{X: 1350, Y: 750, Width: 300, Height: 450}
is.Equal(sm.DipToPhysicalRect(rectDip), rectPhysical) // DipToPhysicalRect screen2
is.Equal(sm.PhysicalToDipRect(rectPhysical), rectDip) // DipToPhysicalRect screen2
rectDip = application.Rect{X: 2200, Y: 250, Width: 200, Height: 300}
rectPhysical = application.Rect{X: 2700, Y: 75, Width: 300, Height: 450}
is.Equal(sm.DipToPhysicalRect(rectDip), rectPhysical) // DipToPhysicalRect outside screen2
is.Equal(sm.PhysicalToDipRect(rectPhysical), rectDip) // DipToPhysicalRect outside screen2
}
func TestScreenManager_PrimaryScreen(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
for _, layout := range exampleLayouts() {
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
is.Equal(sm.GetPrimary(), layout.screens[0]) // Primary screen
}
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 600}},
}})
layout.screens[0], layout.screens[1] = layout.screens[1], layout.screens[0]
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
is.Equal(sm.GetPrimary(), layout.screens[1]) // Primary screen
layout.screens[1].IsPrimary = false
err = sm.LayoutScreens(layout.screens)
is.True(err != nil) // Should error when no primary screen found
}
// Test edge alignment between transformation
// (points and rects on the screen edge should transform to the same precise edge position)
func TestScreenManager_EdgeAlign(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
for _, layout := range exampleLayouts() {
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
for _, screen := range sm.GetAll() {
ptOriginDip := screen.Bounds.Origin()
ptOriginPhysical := screen.PhysicalBounds.Origin()
ptCornerDip := screen.Bounds.InsideCorner()
ptCornerPhysical := screen.PhysicalBounds.InsideCorner()
is.Equal(sm.DipToPhysicalPoint(ptOriginDip), ptOriginPhysical) // DipToPhysicalPoint Origin
is.Equal(sm.PhysicalToDipPoint(ptOriginPhysical), ptOriginDip) // PhysicalToDipPoint Origin
is.Equal(sm.DipToPhysicalPoint(ptCornerDip), ptCornerPhysical) // DipToPhysicalPoint Corner
is.Equal(sm.PhysicalToDipPoint(ptCornerPhysical), ptCornerDip) // PhysicalToDipPoint Corner
rectOriginDip := application.Rect{X: ptOriginDip.X, Y: ptOriginDip.Y, Width: 100, Height: 100}
rectOriginPhysical := application.Rect{X: ptOriginPhysical.X, Y: ptOriginPhysical.Y, Width: 100, Height: 100}
rectCornerDip := application.Rect{X: ptCornerDip.X - 99, Y: ptCornerDip.Y - 99, Width: 100, Height: 100}
rectCornerPhysical := application.Rect{X: ptCornerPhysical.X - 99, Y: ptCornerPhysical.Y - 99, Width: 100, Height: 100}
is.Equal(sm.DipToPhysicalRect(rectOriginDip).Origin(), rectOriginPhysical.Origin()) // DipToPhysicalRect Origin
is.Equal(sm.PhysicalToDipRect(rectOriginPhysical).Origin(), rectOriginDip.Origin()) // PhysicalToDipRect Origin
is.Equal(sm.DipToPhysicalRect(rectCornerDip).Corner(), rectCornerPhysical.Corner()) // DipToPhysicalRect Corner
is.Equal(sm.PhysicalToDipRect(rectCornerPhysical).Corner(), rectCornerDip.Corner()) // PhysicalToDipRect Corner
}
}
}
func TestScreenManager_ProbePoints(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
threshold := 1.0
steps := 3
for _, layout := range exampleLayouts() {
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
for _, screen := range sm.GetAll() {
for i := 0; i <= 1; i++ {
isDip := (i == 0)
var b application.Rect
if isDip {
b = screen.Bounds
} else {
b = screen.PhysicalBounds
}
xStep := b.Width / steps
yStep := b.Height / steps
if xStep < 1 {
xStep = 1
}
if yStep < 1 {
yStep = 1
}
pt := b.Origin()
xDone := false
yDone := false
for !yDone {
if pt.Y > b.InsideCorner().Y {
pt.Y = b.InsideCorner().Y
yDone = true
}
pt.X = b.X
xDone = false
for !xDone {
if pt.X > b.InsideCorner().X {
pt.X = b.InsideCorner().X
xDone = true
}
var ptDblTransformed application.Point
if isDip {
ptDblTransformed = sm.PhysicalToDipPoint(sm.DipToPhysicalPoint(pt))
} else {
ptDblTransformed = sm.DipToPhysicalPoint(sm.PhysicalToDipPoint(pt))
}
is.True(math.Abs(float64(ptDblTransformed.X-pt.X)) <= threshold)
is.True(math.Abs(float64(ptDblTransformed.Y-pt.Y)) <= threshold)
pt.X += xStep
}
pt.Y += yStep
}
}
}
}
}
// Test transformation drift over time
func TestScreenManager_TransformationDrift(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
for _, layout := range exampleLayouts() {
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
for _, screen := range sm.GetAll() {
rectPhysicalOriginal := application.Rect{
X: screen.PhysicalBounds.X + 100,
Y: screen.PhysicalBounds.Y + 100,
Width: 123,
Height: 123,
}
// Slide the position to catch any rounding errors
for i := 0; i < 10; i++ {
rectPhysicalOriginal.X++
rectPhysicalOriginal.Y++
rectPhysical := rectPhysicalOriginal
// Transform back and forth several times to make sure no drift is introduced over time
for j := 0; j < 10; j++ {
rectDip := sm.PhysicalToDipRect(rectPhysical)
rectPhysical = sm.DipToPhysicalRect(rectDip)
}
is.NoErr(matchRects(rectPhysical, rectPhysicalOriginal))
}
}
}
}
func TestScreenManager_ScreenNearestRect(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
{id: 3, w: 1920, h: 1080, s: 1.25, parent: ScreenDefParent{id: 1, align: "l", offset: 0}, name: `23" FHD 96DPI (125%)`},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
type Rects map[string][]application.Rect
t.Run("DIP rects", func(t *testing.T) {
is := is.New(t)
rects := Rects{
"1": []application.Rect{
{X: -150, Y: 260, Width: 400, Height: 300},
{X: -250, Y: 750, Width: 400, Height: 300},
{X: -450, Y: 950, Width: 400, Height: 300},
{X: 800, Y: 1350, Width: 400, Height: 300},
{X: 2000, Y: 100, Width: 400, Height: 300},
{X: 2100, Y: 950, Width: 400, Height: 300},
{X: 2350, Y: 1200, Width: 400, Height: 300},
},
"2": []application.Rect{
{X: 2100, Y: 50, Width: 400, Height: 300},
{X: 2150, Y: 950, Width: 400, Height: 300},
{X: 2450, Y: 1150, Width: 400, Height: 300},
{X: 4300, Y: 400, Width: 400, Height: 300},
},
"3": []application.Rect{
{X: -2000, Y: 100, Width: 400, Height: 300},
{X: -220, Y: 200, Width: 400, Height: 300},
{X: -300, Y: 750, Width: 400, Height: 300},
{X: -500, Y: 900, Width: 400, Height: 300},
},
}
for screenID, screenRects := range rects {
for _, rect := range screenRects {
screen := sm.ScreenNearestDipRect(rect)
is.Equal(screen.ID, screenID)
}
}
})
t.Run("Physical rects", func(t *testing.T) {
is := is.New(t)
rects := Rects{
"1": []application.Rect{
{X: -150, Y: 100, Width: 400, Height: 300},
{X: -250, Y: 1500, Width: 400, Height: 300},
{X: 3600, Y: 100, Width: 400, Height: 300},
},
"2": []application.Rect{
{X: 3700, Y: 100, Width: 400, Height: 300},
{X: 4000, Y: 1150, Width: 400, Height: 300},
},
"3": []application.Rect{
{X: -250, Y: 100, Width: 400, Height: 300},
{X: -300, Y: 950, Width: 400, Height: 300},
{X: -1000, Y: 1000, Width: 400, Height: 300},
},
}
for screenID, screenRects := range rects {
for _, rect := range screenRects {
screen := sm.ScreenNearestPhysicalRect(rect)
is.Equal(screen.ID, screenID)
}
}
})
// DIP rect is near screen1 but when transformed becomes near screen2.
// To have a consistent transformation back & forth, screen nearest physical rect
// should be the one given by ScreenNearestDipRect
t.Run("Edge case 1", func(t *testing.T) {
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1},
{id: 2, w: 1200, h: 1300, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: -20}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
rectDip := application.Rect{X: 1020, Y: 800, Width: 400, Height: 300}
rectPhysical := sm.DipToPhysicalRect(rectDip)
screenDip := sm.ScreenNearestDipRect(rectDip)
screenPhysical := sm.ScreenNearestPhysicalRect(rectPhysical)
is.Equal(screenDip.ID, "2") // screenDip
is.Equal(screenPhysical.ID, "2") // screenPhysical
rectDblTransformed := sm.PhysicalToDipRect(rectPhysical)
is.NoErr(matchRects(rectDblTransformed, rectDip)) // double transformation
})
}
// Unsolved edge cases
func TestScreenManager_UnsolvedEdgeCases(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
// Edge case 1: invalid DIP rect location
// there could be a setup where some dip rects locations are invalid, meaning that there's no
// physical rect that could produce that dip rect at this location
// Not sure how to solve this scenario
t.Run("Edge case 1: invalid dip rect", func(t *testing.T) {
t.Skip("Unsolved edge case")
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1},
{id: 2, w: 1200, h: 1100, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: 0}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
rectDip := application.Rect{X: 1050, Y: 700, Width: 400, Height: 300}
rectPhysical := sm.DipToPhysicalRect(rectDip)
screenDip := sm.ScreenNearestDipRect(rectDip)
screenPhysical := sm.ScreenNearestPhysicalRect(rectPhysical)
is.Equal(screenDip.ID, screenPhysical.ID)
rectDblTransformed := sm.PhysicalToDipRect(rectPhysical)
is.NoErr(matchRects(rectDblTransformed, rectDip)) // double transformation
})
// Edge case 2: physical rect that changes when double transformed
// there could be a setup where a dip rect at some locations could be produced by two different physical rects
// causing one of these physical rects to be changed to the other when double transformed
// Not sure how to solve this scenario
t.Run("Edge case 2: changed physical rect", func(t *testing.T) {
t.Skip("Unsolved edge case")
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5},
{id: 2, w: 1200, h: 900, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
rectPhysical := application.Rect{X: 1050, Y: 890, Width: 400, Height: 300}
rectDblTransformed := sm.DipToPhysicalRect(sm.PhysicalToDipRect(rectPhysical))
is.NoErr(matchRects(rectDblTransformed, rectPhysical)) // double transformation
})
}
func BenchmarkScreenManager_LayoutScreens(b *testing.B) {
sm := application.ScreenManager{}
layouts := exampleLayouts()
screens := layouts[3].screens
b.ResetTimer()
for i := 0; i < b.N; i++ {
sm.LayoutScreens(screens)
}
}
func BenchmarkScreenManager_TransformPoint(b *testing.B) {
sm := application.ScreenManager{}
layouts := exampleLayouts()
screens := layouts[3].screens
sm.LayoutScreens(screens)
pt := application.Point{X: 500, Y: 500}
b.ResetTimer()
for i := 0; i < b.N; i++ {
sm.DipToPhysicalPoint(pt)
}
}
func BenchmarkScreenManager_TransformRect(b *testing.B) {
sm := application.ScreenManager{}
layouts := exampleLayouts()
screens := layouts[3].screens
sm.LayoutScreens(screens)
rect := application.Rect{X: 500, Y: 500, Width: 800, Height: 600}
b.ResetTimer()
for i := 0; i < b.N; i++ {
sm.DipToPhysicalRect(rect)
}
}