From f9ee25f4a9f2535befeefdcbfee237160c3d8f23 Mon Sep 17 00:00:00 2001 From: eugerossetto Date: Wed, 28 Sep 2022 11:56:25 -0300 Subject: [PATCH 1/2] Add win32 package and base dialog wrapper class. Add methods of FileDialogController Add FileDialogController tests. Change the location where the free of the pointer is placed. --- .../file_dialog_controller.dart | 91 ++++++++++++ .../ifile_open_dialog_factory.dart | 13 ++ .../file_selector_windows/pubspec.yaml | 2 + .../file_selector_dart/fake_file_dialog.dart | 112 +++++++++++++++ .../fake_ifile_open_dialog.dart | 55 ++++++++ .../fake_ifile_open_dialog_factory.dart | 40 ++++++ .../file_dialog_controller_test.dart | 131 ++++++++++++++++++ 7 files changed, 444 insertions(+) create mode 100644 packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller.dart create mode 100644 packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_open_dialog_factory.dart create mode 100644 packages/file_selector/file_selector_windows/test/file_selector_dart/fake_file_dialog.dart create mode 100644 packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog.dart create mode 100644 packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog_factory.dart create mode 100644 packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_test.dart diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller.dart new file mode 100644 index 000000000000..65164eb9e40e --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller.dart @@ -0,0 +1,91 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ffi'; + +import 'package:win32/win32.dart'; + +import 'ifile_open_dialog_factory.dart'; + +/// A thin wrapper for IFileDialog to allow for faking and inspection in tests. +/// +/// Since this class defines the end of what can be unit tested, it should +/// contain as little logic as possible. +class FileDialogController { + /// Creates a controller managing [IFileDialog](https://pub.dev/documentation/win32/latest/winrt/IFileDialog-class.html). + /// It also receives an IFileOpenDialogFactory to construct [IFileOpenDialog] + /// instances. + FileDialogController( + IFileDialog fileDialog, IFileOpenDialogFactory iFileOpenDialogFactory) + : _fileDialog = fileDialog, + _iFileOpenDialogFactory = iFileOpenDialogFactory; + + /// The [IFileDialog] to work with. + final IFileDialog _fileDialog; + + /// The [IFileOpenDialogFactory] to work construc [IFileOpenDialog] instances. + final IFileOpenDialogFactory _iFileOpenDialogFactory; + + /// Sets the default folder for the dialog to [path]. It also returns the operation result. + int setFolder(Pointer path) { + return _fileDialog.setFolder(path); + } + + /// Sets the file [name] that is initially shown in the IFileDialog. It also returns the operation result. + int setFileName(String name) { + return _fileDialog.setFileName(TEXT(name)); + } + + /// Sets the allowed file type extensions in the IFileOpenDialog. It also returns the operation result. + int setFileTypes(int count, Pointer filters) { + return _fileDialog.setFileTypes(count, filters); + } + + /// Sets the label of the confirmation button. It also returns the operation result. It also returns the operation result. + int setOkButtonLabel(String text) { + return _fileDialog.setOkButtonLabel(TEXT(text)); + } + + /// Gets the IFileDialog's [options](https://pub.dev/documentation/win32/latest/winrt/FILEOPENDIALOGOPTIONS-class.html), + /// which is a bitfield. It also returns the operation result. + int getOptions(Pointer outOptions) { + return _fileDialog.getOptions(outOptions); + } + + /// Sets the [options](https://pub.dev/documentation/win32/latest/winrt/FILEOPENDIALOGOPTIONS-class.html), + /// which is a bitfield, into the IFileDialog. It also returns the operation result. + int setOptions(int options) { + return _fileDialog.setOptions(options); + } + + /// Shows an IFileDialog using the given parent. It returns the operation result. + int show(int parent) { + return _fileDialog.show(parent); + } + + /// Return results from an IFileDialog. This should be used when selecting + /// single items. It also returns the operation result. + int getResult(Pointer> outItem) { + return _fileDialog.getResult(outItem); + } + + /// Return results from an IFileOpenDialog. This should be used when selecting + /// single items. This function will fail if the IFileDialog* provided to the + /// constructor was not an IFileOpenDialog instance, returning an E_FAIL + /// error. + int getResults(Pointer> outItems) { + IFileOpenDialog? fileOpenDialog; + try { + fileOpenDialog = _iFileOpenDialogFactory.from(_fileDialog); + return fileOpenDialog.getResults(outItems); + } catch (_) { + return E_FAIL; + } finally { + fileOpenDialog?.release(); + if (fileOpenDialog != null) { + free(fileOpenDialog.ptr); + } + } + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_open_dialog_factory.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_open_dialog_factory.dart new file mode 100644 index 000000000000..fca0095db413 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_open_dialog_factory.dart @@ -0,0 +1,13 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:win32/win32.dart'; + +/// A wrapper of the IFileOpenDialog interface to use its from function. +class IFileOpenDialogFactory { + /// Wraps the IFileOpenDialog from function. + IFileOpenDialog from(IFileDialog fileDialog) { + return IFileOpenDialog.from(fileDialog); + } +} diff --git a/packages/file_selector/file_selector_windows/pubspec.yaml b/packages/file_selector/file_selector_windows/pubspec.yaml index ee0701b3fd30..ef6d25d8f8de 100644 --- a/packages/file_selector/file_selector_windows/pubspec.yaml +++ b/packages/file_selector/file_selector_windows/pubspec.yaml @@ -18,9 +18,11 @@ flutter: dependencies: cross_file: ^0.3.1 + ffi: ^2.0.1 file_selector_platform_interface: ^2.2.0 flutter: sdk: flutter + win32: ^3.0.0 dev_dependencies: build_runner: 2.1.11 diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_file_dialog.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_file_dialog.dart new file mode 100644 index 000000000000..84a6713868a6 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_file_dialog.dart @@ -0,0 +1,112 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +// Fake IFileDialog class for testing purposes. +class FakeIFileDialog extends Fake implements IFileDialog { + int _getOptionsCalledTimes = 0; + int _getResultCalledTimes = 0; + int _setOptionsCalledTimes = 0; + int _setFolderCalledTimes = 0; + int _setFileNameCalledTimes = 0; + int _setFileTypesCalledTimes = 0; + int _setOkButtonLabelCalledTimes = 0; + int _showCalledTimes = 0; + + @override + int getOptions(Pointer pfos) { + _getOptionsCalledTimes++; + return S_OK; + } + + @override + int setOptions(int options) { + _setOptionsCalledTimes++; + return S_OK; + } + + @override + int getResult(Pointer> ppsi) { + _getResultCalledTimes++; + return S_OK; + } + + @override + int setFolder(Pointer psi) { + _setFolderCalledTimes++; + return S_OK; + } + + @override + int setFileTypes(int cFileTypes, Pointer rgFilterSpec) { + _setFileTypesCalledTimes++; + return S_OK; + } + + @override + int setFileName(Pointer pszName) { + _setFileNameCalledTimes++; + return S_OK; + } + + @override + int setOkButtonLabel(Pointer pszText) { + _setOkButtonLabelCalledTimes++; + return S_OK; + } + + @override + int show(int hwndOwner) { + _showCalledTimes++; + return S_OK; + } + + void resetCounters() { + _getOptionsCalledTimes = 0; + _getResultCalledTimes = 0; + _setOptionsCalledTimes = 0; + _setFolderCalledTimes = 0; + _setFileTypesCalledTimes = 0; + _setOkButtonLabelCalledTimes = 0; + _showCalledTimes = 0; + _setFileNameCalledTimes = 0; + } + + int getOptionsCalledTimes() { + return _getOptionsCalledTimes; + } + + int setOptionsCalledTimes() { + return _setOptionsCalledTimes; + } + + int getResultCalledTimes() { + return _getResultCalledTimes; + } + + int setFolderCalledTimes() { + return _setFolderCalledTimes; + } + + int setFileNameCalledTimes() { + return _setFileNameCalledTimes; + } + + int setFileTypesCalledTimes() { + return _setFileTypesCalledTimes; + } + + int setOkButtonLabelCalledTimes() { + return _setOkButtonLabelCalledTimes; + } + + int showCalledTimes() { + return _showCalledTimes; + } +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog.dart new file mode 100644 index 000000000000..14dd9eb9f215 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog.dart @@ -0,0 +1,55 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ffi'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +// Fake IFileOpenDialog class for testing purposes. +class FakeIFileOpenDialog extends Fake implements IFileOpenDialog { + int _getResultsCalledTimes = 0; + int _getReleaseCalledTimes = 0; + bool _shouldFail = false; + + @override + Pointer get ptr => nullptr; + + @override + int release() { + _getReleaseCalledTimes++; + return S_OK; + } + + @override + int getResults(Pointer> ppsi) { + _getResultsCalledTimes++; + if (_shouldFail) { + throw WindowsException(E_FAIL); + } + + return S_OK; + } + + void resetCounters() { + _getResultsCalledTimes = 0; + _getReleaseCalledTimes = 0; + } + + int getResultsCalledTimes() { + return _getResultsCalledTimes; + } + + int getReleaseCalledTimes() { + return _getReleaseCalledTimes; + } + + void mockFailure() { + _shouldFail = true; + } + + void mockSuccess() { + _shouldFail = false; + } +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog_factory.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog_factory.dart new file mode 100644 index 000000000000..042a14f56d5a --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog_factory.dart @@ -0,0 +1,40 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file_selector_windows/src/file_selector_dart/ifile_open_dialog_factory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +import 'fake_ifile_open_dialog.dart'; + +// Fake FakeIFileOpenDialogFactory class for testing purposes. +class FakeIFileOpenDialogFactory extends Fake + implements IFileOpenDialogFactory { + int _fromCalledTimes = 0; + bool _shouldFail = false; + + final FakeIFileOpenDialog fakeIFileOpenDialog = FakeIFileOpenDialog(); + + @override + IFileOpenDialog from(IFileDialog dialog) { + _fromCalledTimes++; + if (_shouldFail) { + throw WindowsException(E_NOINTERFACE); + } + + return fakeIFileOpenDialog; + } + + int getFromCalledTimes() { + return _fromCalledTimes; + } + + void mockSuccess() { + _shouldFail = false; + } + + void mockFailure() { + _shouldFail = true; + } +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_test.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_test.dart new file mode 100644 index 000000000000..719a9ad74277 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_test.dart @@ -0,0 +1,131 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:file_selector_windows/src/file_selector_dart/file_dialog_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +import 'fake_file_dialog.dart'; +import 'fake_ifile_open_dialog_factory.dart'; + +void main() { + final FakeIFileDialog fakeFileOpenDialog = FakeIFileDialog(); + final FakeIFileOpenDialogFactory fakeIFileOpenDialogFactory = + FakeIFileOpenDialogFactory(); + final FileDialogController fileDialogController = + FileDialogController(fakeFileOpenDialog, fakeIFileOpenDialogFactory); + + setUp(() { + fakeIFileOpenDialogFactory.mockSuccess(); + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.mockSuccess(); + }); + + tearDown(() { + fakeFileOpenDialog.resetCounters(); + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.resetCounters(); + }); + + test('setFolder should call dialog setFolder', () { + final Pointer ptrFolder = calloc(); + fileDialogController.setFolder(ptrFolder); + free(ptrFolder); + expect(fakeFileOpenDialog.setFolderCalledTimes(), 1); + }); + + test('setFileName should call dialog setFileName', () { + fileDialogController.setFileName('fileName'); + expect(fakeFileOpenDialog.setFileNameCalledTimes(), 1); + }); + + test('setFileTypes should call dialog setFileTypes', () { + final Pointer ptrFilters = calloc(); + fileDialogController.setFileTypes(1, ptrFilters); + free(ptrFilters); + expect(fakeFileOpenDialog.setFileTypesCalledTimes(), 1); + }); + + test('setOkButtonLabel should call dialog setOkButtonLabel', () { + fileDialogController.setOkButtonLabel('button'); + expect(fakeFileOpenDialog.setOkButtonLabelCalledTimes(), 1); + }); + + test('show should call dialog show', () { + fileDialogController.show(0); + expect(fakeFileOpenDialog.showCalledTimes(), 1); + }); + + test('getOptions should call dialog getOptions', () { + final Pointer ptrOptions = calloc(); + fileDialogController.getOptions(ptrOptions); + free(ptrOptions); + expect(fakeFileOpenDialog.getOptionsCalledTimes(), 1); + }); + + test('setOptions should call dialog setOptions', () { + fileDialogController.setOptions(32); + expect(fakeFileOpenDialog.setOptionsCalledTimes(), 1); + }); + + test('getResult should call dialog getResult', () { + final Pointer> ptrCOMObject = + calloc>(); + fileDialogController.getResult(ptrCOMObject); + free(ptrCOMObject); + expect(fakeFileOpenDialog.getResultCalledTimes(), 1); + }); + + test('getResults should call the from method of the factory', () { + final Pointer> ptrCOMObject = + calloc>(); + fileDialogController.getResults(ptrCOMObject); + free(ptrCOMObject); + expect(fakeIFileOpenDialogFactory.getFromCalledTimes(), 1); + }); + + test('getResults should call dialog getResults', () { + final Pointer> ptrCOMObject = + calloc>(); + fileDialogController.getResults(ptrCOMObject); + free(ptrCOMObject); + expect( + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.getResultsCalledTimes(), + 1); + }); + + test( + 'getResults should return an error when building a file open dialog throws', + () { + final Pointer> ptrCOMObject = + calloc>(); + fakeIFileOpenDialogFactory.mockFailure(); + free(ptrCOMObject); + expect(fileDialogController.getResults(ptrCOMObject), E_FAIL); + }); + + test( + 'getResults should return an error and release the dialog when getting results throws', + () { + final Pointer> ptrCOMObject = + calloc>(); + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.mockFailure(); + free(ptrCOMObject); + expect(fileDialogController.getResults(ptrCOMObject), E_FAIL); + expect( + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.getReleaseCalledTimes(), + 1); + }); + + test('getResults should call dialog release', () { + final Pointer> ptrCOMObject = + calloc>(); + fileDialogController.getResults(ptrCOMObject); + free(ptrCOMObject); + expect( + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.getReleaseCalledTimes(), + 1); + }); +} From dff1131b2d4e238a8021d2fd589411a94f7525c9 Mon Sep 17 00:00:00 2001 From: eugerossetto Date: Wed, 28 Sep 2022 11:56:25 -0300 Subject: [PATCH 2/2] Add win32 package and base dialog wrapper class. Add methods of FileDialogController Add FileDialogController tests. Extract the interface conversion to a new class to make it testable. Add DialogWrapper constructor. Add test constructor to DialogWrapper and add two tests. Add mock for FileDialogController and tests for implemented methods. free memory allocations remove unused properties in dialog_wrapper use Arena Return the value of the path. fix extension string list to remove trailing semicolon --- .../src/file_selector_dart/dialog_mode.dart | 12 + .../file_selector_dart/dialog_wrapper.dart | 197 +++++++++++ .../file_dialog_controller_factory.dart | 18 ++ .../ifile_dialog_controller_factory.dart | 14 + .../ifile_dialog_factory.dart | 19 ++ .../file_selector_dart/shell_win32_api.dart | 36 +++ .../dialog_wrapper_test.dart | 306 ++++++++++++++++++ .../dialog_wrapper_test.mocks.dart | 151 +++++++++ .../file_dialog_controller_factory_test.dart | 21 ++ 9 files changed, 774 insertions(+) create mode 100644 packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_mode.dart create mode 100644 packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper.dart create mode 100644 packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller_factory.dart create mode 100644 packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_controller_factory.dart create mode 100644 packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_factory.dart create mode 100644 packages/file_selector/file_selector_windows/lib/src/file_selector_dart/shell_win32_api.dart create mode 100644 packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.dart create mode 100644 packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.mocks.dart create mode 100644 packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_factory_test.dart diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_mode.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_mode.dart new file mode 100644 index 000000000000..ee52efef0a5e --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_mode.dart @@ -0,0 +1,12 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// The kind of file dialog to show. +enum DialogMode { + /// Used for chosing files. + Open, + + /// Used for chosing a directory to save a file. + Save +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper.dart new file mode 100644 index 000000000000..0f018d49cba1 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper.dart @@ -0,0 +1,197 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:core'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:win32/win32.dart'; + +import 'dialog_mode.dart'; +import 'file_dialog_controller.dart'; +import 'ifile_dialog_controller_factory.dart'; +import 'ifile_dialog_factory.dart'; +import 'shell_win32_api.dart'; + +/// Wraps an IFileDialog, managing object lifetime as a scoped object and +/// providing a simplified API for interacting with it as needed for the plugin. +class DialogWrapper { + /// Creates a DialogWrapper using a [IFileDialogControllerFactory] and a [DialogMode]. + /// It is also responsible of creating a [IFileDialog]. + DialogWrapper(IFileDialogControllerFactory fileDialogControllerFactory, + IFileDialogFactory fileDialogFactory, this._dialogMode) + : _isOpenDialog = _dialogMode == DialogMode.Open { + try { + final IFileDialog dialog = fileDialogFactory.createInstace(_dialogMode); + _dialogController = fileDialogControllerFactory.createController(dialog); + _shellWin32Api = ShellWin32Api(); + } catch (ex) { + if (ex is WindowsException) { + _lastResult = ex.hr; + } + } + } + + /// Creates a DialogWrapper for testing purposes. + @visibleForTesting + DialogWrapper.withFakeDependencies(FileDialogController dialogController, + this._dialogMode, this._shellWin32Api) + : _isOpenDialog = _dialogMode == DialogMode.Open, + _dialogController = dialogController; + + int _lastResult = S_OK; + + final DialogMode _dialogMode; + + final bool _isOpenDialog; + + final String _allowAnyValue = 'Any'; + + final String _allowAnyExtension = '*.*'; + + late FileDialogController _dialogController; + + late ShellWin32Api _shellWin32Api; + + /// Returns the result of the last Win32 API call related to this object. + int get lastResult => _lastResult; + + /// Attempts to set the default folder for the dialog to [path], if it exists. + void setFolder(String path) { + if (path == null || path.isEmpty) { + return; + } + + using((Arena arena) { + final Pointer ptrGuid = GUIDFromString(IID_IShellItem); + final Pointer> ptrPath = arena>(); + _lastResult = + _shellWin32Api.createItemFromParsingName(path, ptrGuid, ptrPath); + + if (!SUCCEEDED(_lastResult)) { + return; + } + + _dialogController.setFolder(ptrPath.value); + }); + } + + /// Sets the file name that is initially shown in the dialog. + void setFileName(String name) { + _dialogController.setFileName(name); + } + + /// Sets the label of the confirmation button. + void setOkButtonLabel(String label) { + _dialogController.setOkButtonLabel(label); + } + + /// Adds the given options to the dialog's current [options](https://pub.dev/documentation/win32/latest/winrt/FILEOPENDIALOGOPTIONS-class.html). + /// Both are bitfields. + void addOptions(int newOptions) { + using((Arena arena) { + final Pointer currentOptions = arena(); + _lastResult = _dialogController.getOptions(currentOptions); + if (!SUCCEEDED(_lastResult)) { + return; + } + currentOptions.value |= newOptions; + _lastResult = _dialogController.setOptions(currentOptions.value); + }); + } + + /// Sets the filters for allowed file types to select. + /// filters -> std::optional + void setFileTypeFilters(List filters) { + final Map filterSpecification = {}; + + if (filters.isEmpty) { + filterSpecification[_allowAnyValue] = _allowAnyExtension; + } else { + for (final XTypeGroup option in filters) { + final String? label = option.label; + if (option.allowsAny || option.extensions!.isEmpty) { + filterSpecification[label ?? _allowAnyValue] = _allowAnyExtension; + } else { + final String extensionsForLabel = option.extensions! + .map((String extension) => '*.$extension') + .join(';'); + filterSpecification[label ?? extensionsForLabel] = extensionsForLabel; + } + } + } + + using((Arena arena) { + final Pointer registerFilterSpecification = + arena(filterSpecification.length); + + int index = 0; + for (final String key in filterSpecification.keys) { + registerFilterSpecification[index] + ..pszName = TEXT(key) + ..pszSpec = TEXT(filterSpecification[key]!); + index++; + } + + _lastResult = _dialogController.setFileTypes( + filterSpecification.length, registerFilterSpecification); + }); + } + + /// Displays the dialog, and returns the selected files, or null on error. + List? show(int parentWindow) { + _lastResult = _dialogController.show(parentWindow); + if (!SUCCEEDED(_lastResult)) { + return null; + } + late List? files; + + using((Arena arena) { + final Pointer> shellItemArrayPtr = + arena>(); + final Pointer shellItemCountPtr = arena(); + final Pointer> shellItemPtr = + arena>(); + + files = + _getFilePathList(shellItemArrayPtr, shellItemCountPtr, shellItemPtr); + }); + return files; + } + + List? _getFilePathList( + Pointer> shellItemArrayPtr, + Pointer shellItemCountPtr, + Pointer> shellItemPtr) { + final List files = []; + if (_isOpenDialog) { + _lastResult = _dialogController.getResults(shellItemArrayPtr); + if (!SUCCEEDED(_lastResult)) { + return null; + } + + final IShellItemArray shellItemResources = + IShellItemArray(shellItemArrayPtr.cast()); + _lastResult = shellItemResources.getCount(shellItemCountPtr); + if (!SUCCEEDED(_lastResult)) { + return null; + } + for (int index = 0; index < shellItemCountPtr.value; index++) { + shellItemResources.getItemAt(index, shellItemPtr); + final IShellItem shellItem = IShellItem(shellItemPtr.cast()); + files.add(_shellWin32Api.getPathForShellItem(shellItem)); + } + } else { + _lastResult = _dialogController.getResult(shellItemPtr); + if (!SUCCEEDED(_lastResult)) { + return null; + } + final IShellItem shellItem = IShellItem(shellItemPtr.cast()); + files.add(_shellWin32Api.getPathForShellItem(shellItem)); + } + return files; + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller_factory.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller_factory.dart new file mode 100644 index 000000000000..296c9ba4529f --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller_factory.dart @@ -0,0 +1,18 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:win32/win32.dart'; + +import 'file_dialog_controller.dart'; +import 'ifile_dialog_controller_factory.dart'; +import 'ifile_open_dialog_factory.dart'; + +/// Implementation of FileDialogControllerFactory that makes standard +/// FileDialogController instances. +class FileDialogControllerFactory implements IFileDialogControllerFactory { + @override + FileDialogController createController(IFileDialog dialog) { + return FileDialogController(dialog, IFileOpenDialogFactory()); + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_controller_factory.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_controller_factory.dart new file mode 100644 index 000000000000..63f081b82944 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_controller_factory.dart @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:win32/win32.dart'; + +import 'file_dialog_controller.dart'; + +/// Interface for creating FileDialogControllers, to allow for dependency +/// injection. +abstract class IFileDialogControllerFactory { + /// Returns a FileDialogController to interact with the given [IFileDialog]. + FileDialogController createController(IFileDialog dialog); +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_factory.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_factory.dart new file mode 100644 index 000000000000..4a13d8731a33 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_factory.dart @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:win32/win32.dart'; + +import 'dialog_mode.dart'; + +/// A factory for [IFileDialog] instances. +class IFileDialogFactory { + /// Creates the corresponding IFileDialog instace. The caller is responsible of releasing the resource. + IFileDialog createInstace(DialogMode dialogMode) { + if (dialogMode == DialogMode.Open) { + return FileOpenDialog.createInstance(); + } + + return FileSaveDialog.createInstance(); + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/shell_win32_api.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/shell_win32_api.dart new file mode 100644 index 000000000000..4390e6580aa0 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/shell_win32_api.dart @@ -0,0 +1,36 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:win32/win32.dart'; + +/// A thin wrapper for Win32 platform specific Shell methods. +/// +/// The only purpose of this class is to decouple specific Win32 Api call from the bussiness logic so it can be init tested in any environment. +class ShellWin32Api { + /// Creates and [initializes](https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-shcreateitemfromparsingname) a Shell item object from a parsing name. + /// If the directory doesn't exist it will return an error result. + int createItemFromParsingName(String initialDirectory, Pointer ptrGuid, + Pointer> ptrPath) { + return SHCreateItemFromParsingName( + TEXT(initialDirectory), nullptr, ptrGuid, ptrPath); + } + + /// Returns the path for [shellItem] as a UTF-8 string, or an empty string on + /// failure. + String getPathForShellItem(IShellItem shellItem) { + return using((Arena arena) { + final Pointer> ptrPath = arena>(); + + if (!SUCCEEDED( + shellItem.getDisplayName(SIGDN.SIGDN_FILESYSPATH, ptrPath.cast()))) { + return ''; + } + + return ptrPath.value.toDartString(); + }); + } +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.dart new file mode 100644 index 000000000000..056075cbee02 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.dart @@ -0,0 +1,306 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:file_selector_windows/src/file_selector_dart/dialog_mode.dart'; +import 'package:file_selector_windows/src/file_selector_dart/dialog_wrapper.dart'; +import 'package:file_selector_windows/src/file_selector_dart/file_dialog_controller.dart'; +import 'package:file_selector_windows/src/file_selector_dart/shell_win32_api.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:win32/win32.dart'; + +import 'dialog_wrapper_test.mocks.dart'; + +@GenerateMocks([FileDialogController, ShellWin32Api]) +void main() { + const int defaultReturnValue = S_OK; + late final MockFileDialogController mockFileDialogController = + MockFileDialogController(); + late final MockShellWin32Api mockShellWin32Api = MockShellWin32Api(); + const DialogMode dialogMode = DialogMode.Open; + final DialogWrapper dialogWrapper = DialogWrapper.withFakeDependencies( + mockFileDialogController, dialogMode, mockShellWin32Api); + + setUp(() { + setDefaultMocks(mockFileDialogController, defaultReturnValue); + }); + + tearDown(() { + reset(mockFileDialogController); + reset(mockShellWin32Api); + }); + + test('setFileName should call dialog setFileName', () { + const String folderName = 'Documents'; + dialogWrapper.setFileName(folderName); + verify(mockFileDialogController.setFileName(folderName)).called(1); + }); + + test('setOkButtonLabel should call dialog setOkButtonLabel', () { + const String okButtonLabel = 'Confirm'; + dialogWrapper.setOkButtonLabel(okButtonLabel); + verify(mockFileDialogController.setOkButtonLabel(okButtonLabel)).called(1); + }); + + test('addOptions should call dialog getOptions and setOptions', () { + const int newOptions = FILEOPENDIALOGOPTIONS.FOS_NOREADONLYRETURN; + dialogWrapper.addOptions(newOptions); + verify(mockFileDialogController.getOptions(any)).called(1); + verify(mockFileDialogController.setOptions(newOptions)).called(1); + }); + + test('addOptions should not call setOptions if getOptions returns an error', + () { + const int options = FILEOPENDIALOGOPTIONS.FOS_NOREADONLYRETURN; + when(mockFileDialogController.getOptions(any)).thenReturn(E_FAIL); + dialogWrapper.addOptions(options); + verifyNever(mockFileDialogController.setOptions(any)); + }); + + test( + 'setFileTypeFilters should call setFileTypes with expected typeGroups count', + () { + final List typeGroups = [ + const XTypeGroup(extensions: ['jpg', 'png'], label: 'Images'), + const XTypeGroup(extensions: ['txt', 'json'], label: 'Text'), + ]; + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(typeGroups.length, any)) + .called(1); + }); + + test('setFileTypeFilters should call setFileTypes with Any by default', () { + const String expectedPszName = 'Any'; + const String expectedPszSpec = '*.*'; + final List typeGroups = []; + mockSetFileTypesConditions( + mockFileDialogController, expectedPszName, expectedPszSpec); + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(1, any)).called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test( + 'setFileTypeFilters should call setFileTypes with a label and default extensions', + () { + const String label = 'All files'; + const String expectedPszSpec = '*.*'; + final List typeGroups = [ + const XTypeGroup(label: label), + ]; + mockSetFileTypesConditions( + mockFileDialogController, label, expectedPszSpec); + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(1, any)).called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test( + 'setFileTypeFilters should call setFileTypes with both default label and extensions', + () { + const String defaultLabel = 'Any'; + const String expectedPszSpec = '*.*'; + final List typeGroups = [ + const XTypeGroup(), + ]; + mockSetFileTypesConditions( + mockFileDialogController, defaultLabel, expectedPszSpec); + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(1, any)).called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test( + 'setFileTypeFilters should call setFileTypes with specific labels and extensions', + () { + const String jpg = 'jpg'; + const String png = 'png'; + const String imageLabel = 'Image'; + const String txt = 'txt'; + const String json = 'json'; + const String textLabel = 'Text'; + final Map expectedfilterSpecification = { + imageLabel: '*.$jpg;*.$png', + textLabel: '*.$txt;*.$json', + }; + final List typeGroups = [ + const XTypeGroup(extensions: [jpg, png], label: imageLabel), + const XTypeGroup(extensions: [txt, json], label: textLabel), + ]; + when(mockFileDialogController.setFileTypes(any, any)) + .thenAnswer((Invocation realInvocation) { + final Pointer pointer = + realInvocation.positionalArguments[1] as Pointer; + + int index = 0; + for (final String key in expectedfilterSpecification.keys) { + if (pointer[index].pszName.toDartString() != key || + pointer[index].pszSpec.toDartString() != + expectedfilterSpecification[key]) { + return E_FAIL; + } + index++; + } + return S_OK; + }); + + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(typeGroups.length, any)) + .called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test( + 'setFileTypeFilters should call setFileTypes with specific extensions and No label', + () { + const String jpg = 'jpg'; + const String png = 'png'; + const String txt = 'txt'; + const String json = 'json'; + final Map expectedfilterSpecification = { + '*.$jpg;*.$png': '*.$jpg;*.$png', + '*.$txt;*.$json': '*.$txt;*.$json', + }; + final List typeGroups = [ + const XTypeGroup(extensions: [jpg, png]), + const XTypeGroup(extensions: [txt, json]), + ]; + when(mockFileDialogController.setFileTypes(any, any)) + .thenAnswer((Invocation realInvocation) { + final Pointer pointer = + realInvocation.positionalArguments[1] as Pointer; + + int index = 0; + for (final String key in expectedfilterSpecification.keys) { + if (pointer[index].pszName.toDartString() != key || + pointer[index].pszSpec.toDartString() != + expectedfilterSpecification[key]) { + return E_FAIL; + } + index++; + } + return S_OK; + }); + + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(typeGroups.length, any)) + .called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test('setFolder should not call dialog setFolder if the path is empty', () { + const String emptyPath = ''; + dialogWrapper.setFolder(emptyPath); + verifyNever(mockFileDialogController.setFolder(any)); + }); + + test('setFolder should call dialog setFolder with the provided path', () { + const String path = 'path/to/my/folder'; + when(mockShellWin32Api.createItemFromParsingName(path, any, any)) + .thenReturn(S_OK); + dialogWrapper.setFolder(path); + verify(mockFileDialogController.setFolder(any)).called(1); + }); + + test('setFolder should not call dialog setFolder if createItem fails', () { + const String path = 'path/to/my/folder'; + when(mockShellWin32Api.createItemFromParsingName(path, any, any)) + .thenReturn(E_FAIL); + dialogWrapper.setFolder(path); + verifyNever(mockFileDialogController.setFolder(any)); + }); + + test( + '[DialogMode == Open] show should return null if parent window is not available', + () { + const int parentWindow = 0; + when(mockFileDialogController.show(parentWindow)).thenReturn(E_FAIL); + + final List? result = dialogWrapper.show(parentWindow); + + expect(result, null); + verify(mockFileDialogController.show(parentWindow)).called(1); + verifyNever(mockFileDialogController.getResults(any)); + }); + + test( + "[DialogMode == Open] show should return null if can't get results from dialog", + () { + const int parentWindow = 0; + when(mockFileDialogController.show(parentWindow)).thenReturn(S_OK); + when(mockFileDialogController.getResults(any)).thenReturn(E_FAIL); + + final List? result = dialogWrapper.show(parentWindow); + + expect(result, null); + verify(mockFileDialogController.show(parentWindow)).called(1); + verify(mockFileDialogController.getResults(any)).called(1); + }); + + test( + "[DialogMode == Save] show should return null if can't get result from dialog", + () { + final DialogWrapper dialogWrapperModeSave = + DialogWrapper.withFakeDependencies( + mockFileDialogController, DialogMode.Save, mockShellWin32Api); + const int parentWindow = 0; + when(mockFileDialogController.show(parentWindow)).thenReturn(S_OK); + when(mockFileDialogController.getResult(any)).thenReturn(E_FAIL); + + final List? result = dialogWrapperModeSave.show(parentWindow); + + expect(result, null); + verify(mockFileDialogController.show(parentWindow)).called(1); + verify(mockFileDialogController.getResult(any)).called(1); + }); + + test('[DialogMode == Save] show should the selected directory for', () { + const String filePath = 'path/to/file.txt'; + final DialogWrapper dialogWrapperModeSave = + DialogWrapper.withFakeDependencies( + mockFileDialogController, DialogMode.Save, mockShellWin32Api); + const int parentWindow = 0; + when(mockFileDialogController.show(parentWindow)).thenReturn(S_OK); + when(mockFileDialogController.getResult(any)).thenReturn(S_OK); + when(mockShellWin32Api.getPathForShellItem(any)).thenReturn(filePath); + + final List? result = dialogWrapperModeSave.show(parentWindow); + + expect(result?.first, filePath); + }); +} + +void mockSetFileTypesConditions( + MockFileDialogController mockFileDialogController, + String expectedPszName, + String expectedPszSpec) { + when(mockFileDialogController.setFileTypes(1, any)) + .thenAnswer((Invocation realInvocation) { + final Pointer pointer = + realInvocation.positionalArguments[1] as Pointer; + + return pointer[0].pszName.toDartString() == expectedPszName && + pointer[0].pszSpec.toDartString() == expectedPszSpec + ? S_OK + : E_FAIL; + }); +} + +void setDefaultMocks( + MockFileDialogController mockFileDialogController, int defaultReturnValue) { + when(mockFileDialogController.setOptions(any)).thenReturn(defaultReturnValue); + when(mockFileDialogController.getOptions(any)).thenReturn(defaultReturnValue); + when(mockFileDialogController.setOkButtonLabel(any)) + .thenReturn(defaultReturnValue); + when(mockFileDialogController.setFileName(any)) + .thenReturn(defaultReturnValue); + when(mockFileDialogController.setFileTypes(any, any)) + .thenReturn(defaultReturnValue); + when(mockFileDialogController.setFolder(any)).thenReturn(defaultReturnValue); +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.mocks.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.mocks.dart new file mode 100644 index 000000000000..5227cc12b188 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.mocks.dart @@ -0,0 +1,151 @@ +// Mocks generated by Mockito 5.3.2 from annotations +// in file_selector_windows/test/file_selector_dart/dialog_wrapper_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:ffi' as _i3; + +import 'package:file_selector_windows/src/file_selector_dart/file_dialog_controller.dart' + as _i2; +import 'package:file_selector_windows/src/file_selector_dart/shell_win32_api.dart' + as _i5; +import 'package:mockito/mockito.dart' as _i1; +import 'package:win32/win32.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [FileDialogController]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFileDialogController extends _i1.Mock + implements _i2.FileDialogController { + MockFileDialogController() { + _i1.throwOnMissingStub(this); + } + + @override + int setFolder(_i3.Pointer<_i4.COMObject>? path) => (super.noSuchMethod( + Invocation.method( + #setFolder, + [path], + ), + returnValue: 0, + ) as int); + @override + int setFileName(String? name) => (super.noSuchMethod( + Invocation.method( + #setFileName, + [name], + ), + returnValue: 0, + ) as int); + @override + int setFileTypes( + int? count, + _i3.Pointer<_i4.COMDLG_FILTERSPEC>? filters, + ) => + (super.noSuchMethod( + Invocation.method( + #setFileTypes, + [ + count, + filters, + ], + ), + returnValue: 0, + ) as int); + @override + int setOkButtonLabel(String? text) => (super.noSuchMethod( + Invocation.method( + #setOkButtonLabel, + [text], + ), + returnValue: 0, + ) as int); + @override + int getOptions(_i3.Pointer<_i3.Uint32>? outOptions) => (super.noSuchMethod( + Invocation.method( + #getOptions, + [outOptions], + ), + returnValue: 0, + ) as int); + @override + int setOptions(int? options) => (super.noSuchMethod( + Invocation.method( + #setOptions, + [options], + ), + returnValue: 0, + ) as int); + @override + int show(int? parent) => (super.noSuchMethod( + Invocation.method( + #show, + [parent], + ), + returnValue: 0, + ) as int); + @override + int getResult(_i3.Pointer<_i3.Pointer<_i4.COMObject>>? outItem) => + (super.noSuchMethod( + Invocation.method( + #getResult, + [outItem], + ), + returnValue: 0, + ) as int); + @override + int getResults(_i3.Pointer<_i3.Pointer<_i4.COMObject>>? outItems) => + (super.noSuchMethod( + Invocation.method( + #getResults, + [outItems], + ), + returnValue: 0, + ) as int); +} + +/// A class which mocks [ShellWin32Api]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockShellWin32Api extends _i1.Mock implements _i5.ShellWin32Api { + MockShellWin32Api() { + _i1.throwOnMissingStub(this); + } + + @override + int createItemFromParsingName( + String? initialDirectory, + _i3.Pointer<_i4.GUID>? ptrGuid, + _i3.Pointer<_i3.Pointer<_i3.NativeType>>? ptrPath, + ) => + (super.noSuchMethod( + Invocation.method( + #createItemFromParsingName, + [ + initialDirectory, + ptrGuid, + ptrPath, + ], + ), + returnValue: 0, + ) as int); + @override + String getPathForShellItem(_i4.IShellItem? shellItem) => (super.noSuchMethod( + Invocation.method( + #getPathForShellItem, + [shellItem], + ), + returnValue: '', + ) as String); +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_factory_test.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_factory_test.dart new file mode 100644 index 000000000000..fe100ecdebca --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_factory_test.dart @@ -0,0 +1,21 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file_selector_windows/src/file_selector_dart/file_dialog_controller.dart'; +import 'package:file_selector_windows/src/file_selector_dart/file_dialog_controller_factory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +import 'fake_file_dialog.dart'; + +void main() { + final FileDialogControllerFactory fileDialogControllerFactory = + FileDialogControllerFactory(); + final IFileDialog dialog = FakeIFileDialog(); + + test('createController should return a FileDialogController', () { + expect(fileDialogControllerFactory.createController(dialog), + isA()); + }); +}