From 0c144f5c66a3de2a9fe72346544c1635fff3cf1e Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 23 Jun 2026 11:08:58 -0500 Subject: [PATCH] feat: Initialize Strata.Excel.Core library with export, import, and test utilities This commit introduces the `Strata.Excel.Core` project, a .NET library built on ClosedXML for robust Excel document generation and data import. Key features include: - **Export:** Flexible data export to Excel, supporting custom formatting, titles, subtitles, humanized headings, and batched processing for large datasets. Includes `ExcelContentResult` and `ZipFileContentResult` for ASP.NET Core integration. - **Import:** Utilities to easily import data from Excel worksheets into C# objects. - **Test Utilities:** Comprehensive helpers for comparing Excel workbooks in tests, handling resource extraction, and performing load tests. - **Build Infrastructure:** Sets up a Dockerfile for building the library, including SonarQube and Dependency-Check scanning. - **Project Structure:** Establishes `.gitignore`, `.dockerignore`, `nuget.config`, and a `README.md` with usage instructions and versioning guidelines. This foundational commit provides a reusable and well-tested framework for Excel operations within Strata applications. --- .dockerignore | 10 + .gitignore | 302 +++------- Dockerfile.lib | 89 +++ README.md | 81 ++- Strata.Excel.Core.sln | 37 ++ nuget.config | 10 + sonarsuppressions.xml | 151 +++++ .../ActionResults/ExcelContentResult.cs | 51 ++ .../ActionResults/ZipFileContentResult.cs | 56 ++ src/Strata.Excel.Core/Export/ColumnOptions.cs | 11 + src/Strata.Excel.Core/Export/ExportOptions.cs | 42 ++ src/Strata.Excel.Core/Export/ExportUtils.cs | 418 ++++++++++++++ .../Export/XLColumnExtensions.cs | 32 ++ src/Strata.Excel.Core/Import/ImportUtils.cs | 105 ++++ .../Strata.Excel.Core.csproj | 16 + .../Strata.Excel.TestUtilities.csproj | 12 + .../Utilities/ExcelDocsComparer.cs | 52 ++ .../Utilities/IXLExample.cs | 7 + .../Utilities/PackageHelper.cs | 533 ++++++++++++++++++ .../Utilities/ResourceFileExtractor.cs | 253 +++++++++ .../Utilities/StreamHelper.cs | 191 +++++++ .../Utilities/TestHelper.cs | 145 +++++ .../ExcelExportTests/TestExcelExport.cs | 160 ++++++ .../ExcelExportTests/data.json | 342 +++++++++++ .../ExcelImportTests/TestExcelImport.cs | 80 +++ .../ExcelLoadTests/TestExcelLoad.cs | 76 +++ .../TestCreateExcelWorkbook.xlsx | Bin 0 -> 10097 bytes .../TestCreateExcelWorkbookWithTitlePage.xlsx | Bin 0 -> 11103 bytes ...ExcelWorkbookWithoutHumanizedHeadings.xlsx | Bin 0 -> 10084 bytes .../Strata.Excel.Core.Test.Unit.csproj | 43 ++ 30 files changed, 3087 insertions(+), 218 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.lib create mode 100644 Strata.Excel.Core.sln create mode 100644 nuget.config create mode 100644 sonarsuppressions.xml create mode 100644 src/Strata.Excel.Core/ActionResults/ExcelContentResult.cs create mode 100644 src/Strata.Excel.Core/ActionResults/ZipFileContentResult.cs create mode 100644 src/Strata.Excel.Core/Export/ColumnOptions.cs create mode 100644 src/Strata.Excel.Core/Export/ExportOptions.cs create mode 100644 src/Strata.Excel.Core/Export/ExportUtils.cs create mode 100644 src/Strata.Excel.Core/Export/XLColumnExtensions.cs create mode 100644 src/Strata.Excel.Core/Import/ImportUtils.cs create mode 100644 src/Strata.Excel.Core/Strata.Excel.Core.csproj create mode 100644 src/Strata.Excel.TestUtilities/Strata.Excel.TestUtilities.csproj create mode 100644 src/Strata.Excel.TestUtilities/Utilities/ExcelDocsComparer.cs create mode 100644 src/Strata.Excel.TestUtilities/Utilities/IXLExample.cs create mode 100644 src/Strata.Excel.TestUtilities/Utilities/PackageHelper.cs create mode 100644 src/Strata.Excel.TestUtilities/Utilities/ResourceFileExtractor.cs create mode 100644 src/Strata.Excel.TestUtilities/Utilities/StreamHelper.cs create mode 100644 src/Strata.Excel.TestUtilities/Utilities/TestHelper.cs create mode 100644 tests/Strata.Excel.Core.Test.Unit/ExcelExportTests/TestExcelExport.cs create mode 100644 tests/Strata.Excel.Core.Test.Unit/ExcelExportTests/data.json create mode 100644 tests/Strata.Excel.Core.Test.Unit/ExcelImportTests/TestExcelImport.cs create mode 100644 tests/Strata.Excel.Core.Test.Unit/ExcelLoadTests/TestExcelLoad.cs create mode 100644 tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbook.xlsx create mode 100644 tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbookWithTitlePage.xlsx create mode 100644 tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbookWithoutHumanizedHeadings.xlsx create mode 100644 tests/Strata.Excel.Core.Test.Unit/Strata.Excel.Core.Test.Unit.csproj diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c834108 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.dockerignore +Dockerfile +Dockerfile.local +.env +.gitignore +.vs +.vscode +**/bin +**/obj +**/.toolstarget \ No newline at end of file diff --git a/.gitignore b/.gitignore index ed6d1d2..3f7ac83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,67 @@ -# ---> VisualStudio ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +################### +# compiled source # +################### +*.com +*.class +*.dll +*.exe +*.pdb +*.dll.config +*.cache +*.suo +# Include dlls if they’re in the NuGet packages directory +!/packages/*/lib/*.dll +# Include dlls if they're in the CommonReferences directory +!*CommonReferences/*.dll +#################### +# VS Upgrade stuff # +#################### +_UpgradeReport_Files/ +############### +# Directories # +############### +bin/ +obj/ +TestResults/ +################### +# Web publish log # +################### +*.Publish.xml +############# +# Resharper # +############# +/_ReSharper.* +*.ReSharper.* +############ +# Packages # +############ +# it’s better to unpack these files and commit the raw source +# git has its own built in compression methods +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip +###################### +# Logs and databases # +###################### +*.log +*.sqlite +# OS generated files # +###################### +.DS_Store? +ehthumbs.db +Icon? +Thumbs.db + # User-specific files -*.rsuser -*.suo *.user *.userosscache *.sln.docstates @@ -14,9 +69,6 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs -# Mono auto generated files -mono_crash.* - # Build results [Dd]ebug/ [Dd]ebugPublic/ @@ -24,76 +76,47 @@ mono_crash.* [Rr]eleases/ x64/ x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ +build/ bld/ [Bb]in/ [Oo]bj/ -[Ll]og/ -[Ll]ogs/ -# Visual Studio 2015/2017 cache/options directory +# Visual Studo 2015 cache/options directory .vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* -# NUnit +# NUNIT *.VisualState.xml TestResult.xml -nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core +# DNX project.lock.json -project.fragment.lock.json artifacts/ -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio *_i.c *_p.c -*_h.h +*_i.h *.ilk *.meta *.obj -*.iobj *.pch -*.pdb -*.ipdb *.pgc *.pgd *.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj -*_wpftmp.csproj -*.log -*.tlog *.vspscc *.vssscc .builds @@ -108,21 +131,14 @@ _Chutzpah* ipch/ *.aps *.ncb -*.opendb *.opensdf *.sdf *.cachefile -*.VC.db -*.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx -*.sap - -# Visual Studio Trace Files -*.e2e # TFS 2012 Local Workspace $tf/ @@ -135,29 +151,18 @@ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user +# JustCode is a .NET coding add-in +.JustCode + # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - # NCrunch _NCrunch_* .*crunch*.local.xml -nCrunchTemp_* # MightyMoose *.mm.* @@ -181,76 +186,50 @@ DocProject/Help/html # Click-Once directory publish/ +ClickOnce/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, +# TODO: Comment the next line if you want to checkin your web deploy settings # but database connection strings (with potential passwords) will be unencrypted -*.pubxml *.publishproj -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - # NuGet Packages *.nupkg -# NuGet Symbol Packages -*.snupkg # The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* +**/packages/* # except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ +!**/packages/build/ # Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets +#!**/packages/repositories.config -# Microsoft Azure Build Output +# Windows Azure Build Output csx/ *.build.csdef -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files +# Windows Store app package directory AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache -!?*.[Cc]ache/ +!*.[Cc]ache/ # Others ClientBin/ +[Ss]tyle[Cc]op.* ~$* *~ *.dbmdl *.dbproj.schemaview -*.jfm *.pfx *.publishsettings +node_modules/ +bower_components/ orleans.codegen.cs -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - # RIA/Silverlight projects Generated_Code/ @@ -261,32 +240,21 @@ _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak # SQL Server files *.mdf *.ldf -*.ndf - +**/node_modules/* # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ -# GhostDoc plugin setting file -*.GhostDoc.xml - # Node.js Tools for Visual Studio .ntvs_analysis.dat -node_modules/ # Visual Studio 6 build log *.plg @@ -294,109 +262,9 @@ node_modules/ # Visual Studio 6 workspace options file *.opt -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml +# SonarQube files +sonarqube/ +.vs/ +*.orig +buildlogs/ \ No newline at end of file diff --git a/Dockerfile.lib b/Dockerfile.lib new file mode 100644 index 0000000..0e66784 --- /dev/null +++ b/Dockerfile.lib @@ -0,0 +1,89 @@ +ARG PROJECT=Strata.Excel.Core +ARG VERSION=0.0.0 +ARG SONARURL='' +ARG SONARLOGIN='' +ARG SONARBRANCH='' + +############### +# Build image # +############### +FROM ecr.ops.stratanetwork.net/strata.microsoft.dotnet.sdk:6.0 AS build +ARG PROJECT +ARG VERSION +ARG SONARLOGIN +ARG SONARURL +ARG SONARBRANCH + +RUN apt-get --allow-releaseinfo-change update && apt-get install -y libgdiplus + +WORKDIR /src + +# Copy project files and restore +COPY ["${PROJECT}.sln", "${PROJECT}.sln"] +COPY src/**/*.csproj ./ +RUN for file in $(ls *.csproj); do mkdir -p src/${file%.*}/ && mv $file src/${file%.*}/; done +COPY tests/**/*.csproj ./ +RUN for file in $(ls *.csproj); do mkdir -p tests/${file%.*}/ && mv $file tests/${file%.*}/; done + +RUN dotnet restore "${PROJECT}.sln" \ + --source https://api.nuget.org/v3/index.json \ + --source https://proget.sdt.local/nuget/nuget/v3/index.json + + +# Copy files and build +COPY . . + +RUN if [ "${SONARLOGIN}" != "" ] ; then dotnet sonarscanner begin \ + /k:"${PROJECT}" \ + /d:sonar.scm.provider=git \ + /d:sonar.host.url="${SONARURL}" \ + /d:sonar.login="${SONARLOGIN}" \ + ${SONARBRANCH} \ + /d:sonar.cs.opencover.reportsPaths="/src/cover.xml" \ + /d:sonar.dependencyCheck.jsonReportPath=/src/buildlogs/dependency-check-report.json \ + /d:sonar.dependencyCheck.htmlReportPath=/src/buildlogs/dependency-check-report.html \ + /v:sonar.projectVersion="${VERSION}"; fi + + +RUN dotnet build "${PROJECT}.sln" \ + --configuration Release \ + --no-restore + +# Run unit tests +RUN dotnet test "${PROJECT}.sln" \ + --configuration Release \ + --no-restore \ + --no-build \ + --verbosity=normal \ + -p:CollectCoverage=true \ + -p:CoverletOutputFormat="opencover" \ + -p:CoverletOutput=/src/cover.xml + +RUN dotnet pack "src/Strata.Excel.Core/Strata.Excel.Core.csproj" \ + --configuration Release \ + --no-restore \ + --no-build \ + --include-symbols \ + --include-source \ + --output /pack \ + -property:PackageVersion=${VERSION} + +RUN dotnet pack "src/Strata.Excel.TestUtilities/Strata.Excel.TestUtilities.csproj" \ + --configuration Release \ + --no-restore \ + --no-build \ + --include-symbols \ + --include-source \ + --output /pack \ + -property:PackageVersion=${VERSION} + + +RUN if [ "${SONARLOGIN}" != "" ] ; then \ + /dc/dependency-check/bin/dependency-check.sh -f JSON -f HTML -s . -o ./buildlogs \ + --suppression ./sonarsuppressions.xml \ + --noupdate --nodeAuditSkipDevDependencies --disableNodeJS \ + --dbDriverName "com.microsoft.sqlserver.jdbc.SQLServerDriver" \ + --connectionString "jdbc:sqlserver://ddensqldevops01.sdt.local;Database=DependencyCheck;encrypt=true;trustServerCertificate=true;" \ + --dbUser "dcuser" --dbPassword "${SONARLOGIN}" && \ + dotnet sonarscanner end /d:sonar.login="${SONARLOGIN}"; \ + fi diff --git a/README.md b/README.md index 5cd2718..f044ac4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ -# excel.core +# Strata.Excel.Core +This provides a library for Strata standard ClosedXML Excel documents + +[![build](https://github.com/stratadecision/excel.core/actions/workflows/build.yaml/badge.svg)](https://github.com/stratadecision/excel.core/actions/workflows/build.yaml) + + +[![SonarQube](https://img.shields.io/badge/SonarQube-Strata.excel.core-004880?logo=sonarqube)](https://sonarqube.sdt.local/dashboard?id=Strata.excel.core) + +[![Strata.Excel.Core](https://img.shields.io/badge/nuget-Strata.Excel.Core-004880?logo=nuget&logoColor=004880)](https://proget.ops.stratanetwork.net/feeds/nuget/Strata.Excel.Core/versions?HidePrerelease=True) +[![Strata.Excel.TestUtilities](https://img.shields.io/badge/nuget-Strata.Excel.TestUtilities-004880?logo=nuget&logoColor=004880)](https://proget.ops.stratanetwork.net/feeds/nuget/Strata.Excel.TestUtilities/versions?HidePrerelease=True) + + + +## Branching and Versioning + +| branch | version format | example | +| ---------- | --------------------- | --------------- | +| main | #.#.# | 1.2.3 | +| feature/\* | #.#+1.0-featureName.# | 1.3.0-newfeat.1 | +| fix/\* | #.#.#+1-fixName.# | 1.2.4-bug.1 | + +See our [confluence page](https://confluence.sdt.local/display/DOP/Branching+and+Versioning) for more information + +## Usage + +### Build and Run + +``` +docker-compose up -d --build +``` + +- https://localhost:8443/index (swagger api) +- https://localhost:8443/hangfire (hangfire dashboard) +- http://localhost:8081 (redis commander) + +### Extracting nuget package + +``` +docker create --name throwaway strataexcel-excel-core +docker cp throwaway:/pack . +docker rm throwaway +``` + +### Cleanup + +``` +docker-compose down +``` + + +# Helpful Features +### Adding a drop down list to a worksheet + +ClosedXML offers the functionality for adding drop down lists to columns with validation out of the box. However, that implementation has a data length limitation of a formula field. For lists longer than trivial selections this extension method can be used: + +```csharp +/// Drop down list items +/// Name of hidden worksheet that holds data +/// Sort items in ascending order +CreateDataValidation(this IXLColumn column, IEnumerable items, string hiddenWorksheetName, bool orderItems = true) +``` +### Example usage +```csharp +var workbook = new XLWorkbook(); +var worksheet= wb.Worksheets.Add("Strata Data Worksheet"); + +// Illustrating a list longer than standard Data Validation can handle +var dropDownListItems = new List(){"Department A", "Department B", "Department C", "Department D", "Department E", "Department F" +"Department G", "Department H", "Department I", "Department J", "Department K", "Department L" +"Department M", "Department N", "Department O", "Department P", "Department Q", "Department R"}; + +// Add drop down with list items to all cells in column 1 +worksheet.Column(1).CreateDataValidation(dropDownListItems, "Name of Worksheet"); +``` + +### Notes +* Worksheets must have unique names in excel therefore an overload for worksheet name is provided. When adding more than one drop down to a single worksheet it will be necessary to ensure each worksheet has a unique name. +* Data validation is implemented by adding a hidden worksheet to your workbook that contains all of the desired list items, validation is performed against the hidden worksheet's list. + + diff --git a/Strata.Excel.Core.sln b/Strata.Excel.Core.sln new file mode 100644 index 0000000..c9fce72 --- /dev/null +++ b/Strata.Excel.Core.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29613.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Strata.Excel.Core.Test.Unit", "tests\Strata.Excel.Core.Test.Unit\Strata.Excel.Core.Test.Unit.csproj", "{D23434FF-E782-41BE-B583-B0518EADC97E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Strata.Excel.Core", "src\Strata.Excel.Core\Strata.Excel.Core.csproj", "{404933A0-C95F-4E2E-9A76-95EA761D7F80}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Strata.Excel.TestUtilities", "src\Strata.Excel.TestUtilities\Strata.Excel.TestUtilities.csproj", "{85C88D7C-F2CB-4A2D-952D-C695CF3DDA60}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D23434FF-E782-41BE-B583-B0518EADC97E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D23434FF-E782-41BE-B583-B0518EADC97E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D23434FF-E782-41BE-B583-B0518EADC97E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D23434FF-E782-41BE-B583-B0518EADC97E}.Release|Any CPU.Build.0 = Release|Any CPU + {404933A0-C95F-4E2E-9A76-95EA761D7F80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {404933A0-C95F-4E2E-9A76-95EA761D7F80}.Debug|Any CPU.Build.0 = Debug|Any CPU + {404933A0-C95F-4E2E-9A76-95EA761D7F80}.Release|Any CPU.ActiveCfg = Release|Any CPU + {404933A0-C95F-4E2E-9A76-95EA761D7F80}.Release|Any CPU.Build.0 = Release|Any CPU + {85C88D7C-F2CB-4A2D-952D-C695CF3DDA60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85C88D7C-F2CB-4A2D-952D-C695CF3DDA60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85C88D7C-F2CB-4A2D-952D-C695CF3DDA60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85C88D7C-F2CB-4A2D-952D-C695CF3DDA60}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7EBD3752-3378-42B4-8E49-2CFCE925B854} + EndGlobalSection +EndGlobal diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..3bf3f7b --- /dev/null +++ b/nuget.config @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/sonarsuppressions.xml b/sonarsuppressions.xml new file mode 100644 index 0000000..6f4969c --- /dev/null +++ b/sonarsuppressions.xml @@ -0,0 +1,151 @@ + + + + + ^pkg:generic/TeamCity\.ServiceMessages@.*$ + cpe:/a:jetbrains:teamcity + CVE-2014-10002 + + + + ^pkg:generic/Microsoft\.AspNetCore\.Authentication\.JwtBearer@.*$ + CVE-2020-1108 + + + + ^pkg:generic/Microsoft\.VisualStudio\.CodeCoverage\.Shim@.*$ + CVE-2020-1171 + + + + ^pkg:generic/Microsoft\.VisualStudio\.CodeCoverage\.Shim@.*$ + CVE-2020-1192 + + + + ^pkg:generic/SonarScanner\.MSBuild\.Tasks@.*$ + CVE-2020-22475 + + + + ^pkg:npm/browserslist@.*$ + 1747 + + + + ^pkg:npm/css\-what@.*$ + 1754 + + + + ^pkg:npm/dns\-packet@.*$ + 1745 + + + + ^pkg:npm/normalize\-url@.*$ + 1755 + + + + ^pkg:npm/trim\-newlines@.*$ + 1753 + + + + ^pkg:generic/TeamCity\.ServiceMessages@.*$ + CVE-2014-10036 + + + + ^pkg:generic/TeamCity\.ServiceMessages@.*$ + CVE-2019-12156 + + + + ^pkg:generic/TeamCity\.ServiceMessages@.*$ + CVE-2019-12157 + + + + ^pkg:generic/TeamCity\.ServiceMessages@.*$ + CVE-2019-12841 + + + + ^pkg:generic/TeamCity\.ServiceMessages@.*$ + CVE-2019-12842 + + + + ^pkg:generic/TeamCity\.ServiceMessages@.*$ + CVE-2019-12843 + + + + ^pkg:generic/TeamCity\.ServiceMessages@.*$ + CVE-2019-12844 + + + + ^pkg:generic/TeamCity\.ServiceMessages@.*$ + CVE-2019-12845 + + + + ^pkg:generic/TeamCity\.VSTest\.TestLogger@.*$ + cpe:/a:jetbrains:teamcity + + + + ^pkg:generic/TeamCity\.VSTest\.TestAdapter@.*$ + cpe:/a:jetbrains:teamcity + + + + ^pkg:javascript/jquery@.*$ + Regex in its jQuery.htmlPrefilter sometimes may introduce XSS + + \ No newline at end of file diff --git a/src/Strata.Excel.Core/ActionResults/ExcelContentResult.cs b/src/Strata.Excel.Core/ActionResults/ExcelContentResult.cs new file mode 100644 index 0000000..9a7e579 --- /dev/null +++ b/src/Strata.Excel.Core/ActionResults/ExcelContentResult.cs @@ -0,0 +1,51 @@ +using ClosedXML.Excel; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading.Tasks; + +namespace Strata.Excel.Core +{ + [ExcludeFromCodeCoverage] + public class ExcelContentResult : ActionResult + { + public IXLWorkbook Workbook { get; set; } + public string FileName { get; set; } + + public ExcelContentResult() + { + } + + public ExcelContentResult(IXLWorkbook workbook, string fileName = "Export") : base() + { + Workbook = workbook; + FileName = fileName; + } + + public override void ExecuteResult(ActionContext context) + { + GetFileContentResult().ExecuteResult(context); + } + + public override Task ExecuteResultAsync(ActionContext context) + { + return GetFileContentResult().ExecuteResultAsync(context); + } + + protected FileContentResult GetFileContentResult() + { + // Flush the workbook to the Response.OutputStream + using (var memoryStream = new MemoryStream()) + { + Workbook.SaveAs(memoryStream); + + return new FileContentResult(memoryStream.ToArray(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = $"{FileName}_{DateTime.UtcNow:MM-dd-yyyy}.xlsx" + }; + } + } + } +} diff --git a/src/Strata.Excel.Core/ActionResults/ZipFileContentResult.cs b/src/Strata.Excel.Core/ActionResults/ZipFileContentResult.cs new file mode 100644 index 0000000..e6f3cfe --- /dev/null +++ b/src/Strata.Excel.Core/ActionResults/ZipFileContentResult.cs @@ -0,0 +1,56 @@ +using ClosedXML.Excel; +using Ionic.Zip; +using Microsoft.AspNetCore.Mvc; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading.Tasks; + +namespace Strata.Excel.Core +{ + [ExcludeFromCodeCoverage] + public class ZipFileContentResult : ActionResult + { + public IXLWorkbook Workbook { get; set; } + public string FileName { get; set; } + + public ZipFileContentResult() + { + } + + public ZipFileContentResult(IXLWorkbook workbook, string fileName = "Export") : base() + { + Workbook = workbook; + FileName = fileName; + } + + public override void ExecuteResult(ActionContext context) + { + GetFileContentResult().ExecuteResult(context); + } + + public override Task ExecuteResultAsync(ActionContext context) + { + return GetFileContentResult().ExecuteResultAsync(context); + } + + protected FileContentResult GetFileContentResult() + { + var zip = new ZipFile($"{FileName}.zip"); + using (var fs = new MemoryStream()) + { + Workbook.SaveAs(fs); + fs.Position = 0; + zip.AddEntry($"{FileName}.xlsx", fs); + // Flush the zipfile to the Response.OutputStream + MemoryStream output = new MemoryStream(); + zip.Save(output); + return new FileContentResult(output.ToArray(), + "application/zip") + { + FileDownloadName = $"{FileName}.zip" + }; + } + } + + } +} diff --git a/src/Strata.Excel.Core/Export/ColumnOptions.cs b/src/Strata.Excel.Core/Export/ColumnOptions.cs new file mode 100644 index 0000000..1c8df79 --- /dev/null +++ b/src/Strata.Excel.Core/Export/ColumnOptions.cs @@ -0,0 +1,11 @@ +using ClosedXML.Excel; + +namespace Strata.Excel.Core +{ + public class ColumnOptions + { + public string Column { get; set; } + public string Format { get; set; } + public XLAlignmentHorizontalValues Alignment { get; set; } = XLAlignmentHorizontalValues.Left; + } +} diff --git a/src/Strata.Excel.Core/Export/ExportOptions.cs b/src/Strata.Excel.Core/Export/ExportOptions.cs new file mode 100644 index 0000000..979dbd0 --- /dev/null +++ b/src/Strata.Excel.Core/Export/ExportOptions.cs @@ -0,0 +1,42 @@ +using ClosedXML.Excel; +using Humanizer; +using System; +using System.Collections.Generic; +using static Strata.Excel.Core.ExportUtils; + +namespace Strata.Excel.Core +{ + public class ExportOptions + { + public string Title { get; set; } + public string SubTitle { get; set; } + public string DefaultWorksheetName { get; set; } + public string EmptyMessage { get; set; } + public XLEventTracking EventTracking { get; set; } = XLEventTracking.Disabled; + public bool HumanizeHeading { get; set; } = true; + public LetterCasing HumanizeLetterCasing { get; set; } = LetterCasing.Title; + public int BatchSize { get; set; } = 0; + + public List ColumnOptions { get; set; } + + public ExportOptions() + { + DefaultWorksheetName = "Sheet1"; + EmptyMessage = "No Data Available"; + ColumnOptions = new List(); + } + + public void AddColumnOptions(string column, string format) + => this.ColumnOptions.Add(new ColumnOptions() { Column = column, Format = format }); + + public void AddColumnOptions(string column, string format, XLAlignmentHorizontalValues alignment) + => this.ColumnOptions.Add(new ColumnOptions() { Column = column, Format = format, Alignment = alignment }); + + public void AddColumnOptions(string column, NumberFormatId numberFormatId) + => AddColumnOptions(column, Enum.GetName(typeof(NumberFormatId), numberFormatId)); + + public void AddColumnOptions(string column, NumberFormatId numberFormatId, XLAlignmentHorizontalValues alignment) + => AddColumnOptions(column, Enum.GetName(typeof(NumberFormatId), numberFormatId), alignment); + + } +} diff --git a/src/Strata.Excel.Core/Export/ExportUtils.cs b/src/Strata.Excel.Core/Export/ExportUtils.cs new file mode 100644 index 0000000..b6c90ce --- /dev/null +++ b/src/Strata.Excel.Core/Export/ExportUtils.cs @@ -0,0 +1,418 @@ +using ClosedXML.Excel; +using ClosedXML.Graphics; +using Humanizer; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Strata.Excel.Core +{ + [ExcludeFromCodeCoverage] + public static class ExportUtils + { + /// + /// 0 General + ///1 0 + ///2 0.00 + ///3 #,##0 + ///4 #,##0.00 + ///9 0% + ///10 0.00% + ///11 0.00E+00 + ///12 # ?/? + ///13 # ??/?? + ///14 d/m/yyyy + ///15 d-mmm-yy + ///16 d-mmm + ///17 mmm-yy + ///18 h:mm tt + ///19 h:mm:ss tt + ///20 H:mm + ///21 H:mm:ss + ///22 m/d/yyyy H:mm + ///37 #,##0 ;(#,##0) + ///38 #,##0 ;[Red](#,##0) + ///39 #,##0.00;(#,##0.00) + ///40 #,##0.00;[Red](#,##0.00) + ///45 mm:ss + ///46 [h]:mm:ss + ///47 mmss.0 + ///48 ##0.0E+0 + ///49 @ + /// + public enum NumberFormatId + { + General, + Zero, + ZeroDotZeroZero, + PoundCommaPoundPoundZero, + PoundCommaPoundPoundZeroDotZeroZero, + ZeroPercent = 9, + ZeroDotZeroZeroPercent = 10, + ScientificNotation = 11, + PoundSpaceQuestionSlashQuestion = 12, + PoundSpaceQuestionQuestionSlashQuestionQuestion = 13, + ShortDateSlash = 14, + ShortDateDash = 15, + DDashMMM = 16, + MMMDashYY = 17, + ShortTime = 18, + ShortTimeSeconds = 19, + ShortTime24 = 20, + ShortTimeSeconds24 = 21, + ShortDateTime = 22, + PoundCommaPoundPoundZeroNegative = 37, + PoundCommaPoundPoundZeroRedNegative = 38, + PoundCommaPoundPoundZeroDotZeroZeroNegative = 39, + PoundCommaPoundPoundZeroDotZeroZeroRedNegative = 40, + MinutesSeconds = 45, + HoursOptionalMinutesSeconds = 46, + MinutesSecondsMilliseconds = 47, + PoundPoundScientificNotation = 48, + HideZeroValues = 49 + } + + internal static Dictionary NumberFormatDictionary => new Dictionary() + { + {"General", 0}, + {"0", 1}, + {"Accounting",1}, + {"0.00", 2}, + {"Accounting2Dec", 2}, + {"#,##0", 3}, + {"Currency",3}, + {"#,##0.00", 4}, + {"Currency2Dec", 4}, + {"0%", 9}, + {"ZeroPercent", 9}, + {"0.00%", 10}, + {"ZeroPerentWithDecimals", 10}, + {"0.00E+00", 11}, + {"Scientific", 11}, + {"# ?/?", 12}, + {"Fractions", 12}, + {"# ??/??", 13}, + {"mm-dd-yy", 14}, + {"ShortDateSlash", 14}, + {"d-mmm-yy", 15}, + {"ShortDateDash", 15}, + {"d-mmm", 16}, + {"DDashMMM", 16}, + {"mmm-yy", 17}, + {"MMMDashYY", 17}, + {"h:mm AM/PM", 18}, + {"ShortTime", 18}, + {"h:mm:ss AM/PM", 19}, + {"ShortTimeSeconds",19}, + {"h:mm", 20}, + {"ShortTime24", 20}, + {"h:mm:ss", 21}, + {"ShortTimeSeconds24",21}, + {"m/d/yy h:mm", 22}, + {"ShortDateTime",22}, + {"#,##0 ;(#,##0)", 37}, + {"NoLeadingZeroNegative",37}, + {"#,##0 ;[Red](#,##0)", 38}, + {"NoLeadingZeroRedNegative",38}, + {"#,##0.00;(#,##0.00)", 39}, + {"NoLeadingZeroDecimalsNegative",39}, + {"#,##0.00;[Red](#,##0.00)", 40}, + {"NoLeadingZeroDecimalsRedNegative",40}, + {"mm:ss", 45}, + {"MinutesSeconds", 45}, + {"[h]:mm:ss", 46}, + {"HoursOptionalMinutesSeconds",46}, + {"mmss.0", 47}, + {"MinutesSecondsMilliseconds", 47}, + {"##0.0E+0", 48}, + {"ScientificNotation",48}, + {"@", 49}, + {"HideZeroValues", 49} + }; + + public static string CellName(this IXLWorksheet ws, int row, int col) => ws.Cell(row, col).Address.ToString(); + + public static IXLWorksheet WriteFormField(this IXLWorksheet ws, int row, int col, string label, string value) + { + ws.Cell(row, col).Value = $"{label}:"; + ws.Cell(row, col + 1).Value = value; + return ws; + } + + public static IXLWorksheet WriteSheetTitle(this IXLWorksheet ws, int row, int col, int offset, string title) + { + var cellRow = ws.Row(row); + cellRow.Height = 30; + var cells = ws.Range(row, col, row, offset); + cells.Merge(); + cells.Style.Font.Bold = true; + cells.Style.Font.FontColor = XLColor.FromArgb(31, 73, 125); + cells.Style.Font.FontSize = 18; + cells.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + cells.Value = title; + return ws; + } + + public static IXLWorksheet WriteSheetSubTitle(this IXLWorksheet ws, int row, int col, int offset, string subtitle) + { + var cells = ws.Range(row, col, row, offset); + cells.Merge(); + cells.Style.Font.Italic = true; + cells.Style.Font.FontColor = XLColor.FromArgb(237, 125, 49); + cells.Value = subtitle; + return ws; + } + + public static IXLWorksheet WriteHeader(this IXLWorksheet ws, int row, int col, int offset, string label) + { + var cells = ws.Range(row, col, row, offset); + cells.Merge(); + cells.Style.Font.Bold = true; + cells.Style.Font.FontColor = XLColor.FromArgb(31, 73, 125); + cells.Style.Font.FontSize = 16; + cells.Style.Border.BottomBorder = XLBorderStyleValues.Medium; + cells.Style.Border.BottomBorderColor = XLColor.FromArgb(148, 179, 215); + cells.Value = label; + return ws; + } + + public static IXLWorksheet WriteCellValue(this IXLWorksheet ws, int row, int col, object value) + { + var cell = ws.Cell(row, col); + cell.Value = value; + return ws; + } + + internal static IXLWorksheet WriteHeading(this IXLWorksheet ws, int row, int col, string value) + { + var cell = ws.Cell(row, col); + cell.Style.Font.Bold = true; + cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + cell.Value = value; + return ws; + } + + public static IXLWorksheet CreateTitlePage(this XLWorkbook wb, string title, string subTitle) + { + var ws = wb.AddWorksheet(title); + ws.WriteSheetTitle(1, 1, 8, title) + .WriteHeader(3, 1, 8, subTitle) + .WriteFormField(5, 1, "Report Date", DateTime.Today.ToString("g")); + ws.Columns().AdjustToContents(); + return ws; + } + + public static IXLWorksheet CreateWorksheet(this XLWorkbook wb, IEnumerable items, string worksheetName) + => wb.CreateWorksheet(items, worksheetName, new ExportOptions()); + + public static IXLWorksheet CreateWorksheet(this XLWorkbook wb, IEnumerable items, string worksheetName, ExportOptions options) + { + wb.AddWorksheet(worksheetName); + var ws = wb.Worksheet(worksheetName); + + IXLTable table = null; + if (options.BatchSize <= 0 || options.BatchSize > items.Count()) + { + table = ws.Cell(1, 1).InsertTable(items); + ws.HumanizeHeadings(table, options); + } + else + { + var batches = items.Batch(options.BatchSize); + table = ws.Cell(1, 1).InsertTable(batches.First()); + ws.HumanizeHeadings(table, options); + foreach (var batch in batches.Skip(1)) + { + table.AppendData(batch); + } + } + ws.Columns().AdjustToContents(); + return ws; + } + + internal static IXLWorksheet HumanizeHeadings(this IXLWorksheet ws, IXLTable table, ExportOptions options) + { + var columns = table.ColumnsUsed().Select(t => t.ColumnNumber()); + foreach (var colNo in columns) + { + var heading = ws.Cell(1, colNo).GetString(); + ws.Cell(1, colNo).Value = options.HumanizeHeading + ? heading.Humanize(options.HumanizeLetterCasing) + : heading.Replace(" ", ""); + } + return ws; + } + + public static IXLWorksheet CreateWorksheet(this XLWorkbook wb, IEnumerable items, ExportOptions options) + { + var ws = wb.CreateWorksheet(items, options.DefaultWorksheetName, options); + // format columns + options.ColumnOptions.ForEach(ws.SetColumnFormatByHeader); + return ws; + } + + public static XLWorkbook CreateExcelWorkbook(IEnumerable data, ExportOptions options) + { + if (data is null || !data.Any()) + { + return GetEmptyWorkbook(options); + } + + // https://github.com/ClosedXML/ClosedXML/wiki/Graphic-Engine + // Only workbooks created with the options will use the engine + // https://github.com/ClosedXML/ClosedXML/wiki/Turning-off-events + // Disabling Event Tracking to save memory and increase performance + var loadOptions = new LoadOptions + { + GraphicEngine = new DefaultGraphicEngine("Times New Roman"), + EventTracking = XLEventTracking.Disabled + }; + // load data into worksheet + var workbook = new XLWorkbook(loadOptions); + if (!string.IsNullOrEmpty(options.Title)) + { + workbook.CreateTitlePage(options.Title, options.SubTitle); + } + var ws = workbook.CreateWorksheet(data, options.DefaultWorksheetName, options); + + // format columns + options.ColumnOptions.ForEach(ws.SetColumnFormatByHeader); + + return workbook; + } + + private static XLWorkbook GetEmptyWorkbook(ExportOptions options) + { + // https://github.com/ClosedXML/ClosedXML/wiki/Graphic-Engine + // Only workbooks created with the options will use the engine + // https://github.com/ClosedXML/ClosedXML/wiki/Turning-off-events + // Disabling Event Tracking to save memory and increase performance + var loadOptions = new LoadOptions + { + GraphicEngine = new DefaultGraphicEngine("Times New Roman"), + EventTracking = XLEventTracking.Disabled + }; + var workbook = new XLWorkbook(loadOptions); + if (!string.IsNullOrEmpty(options.Title)) + { + workbook.CreateTitlePage(options.Title, options.SubTitle); + } + var ws = workbook.AddWorksheet("Default Worksheet"); + ws.Cell(1, 1).Value = "No Data"; + return workbook; + } + + internal static IXLCell CellByHeader(this IXLWorksheet ws, string columnHeader) + { + return ws.Tables.First().HeadersRow().CellsUsed(c => c.Value.ToString() == columnHeader).FirstOrDefault(); + } + + internal static IXLColumn ColumnByHeader(this IXLWorksheet ws, string columnHeader) + { + return ws.Tables.FirstOrDefault()?.HeadersRow() + .CellsUsed(c => c.Value.ToString() == columnHeader).FirstOrDefault()?.WorksheetColumn(); + } + + internal static void SetColumnFormatByHeader(this IXLWorksheet ws, ColumnOptions columnOptions) + { + SetColumnFormatByHeader(ws, columnOptions.Column, columnOptions.Format); + var column = ws.ColumnByHeader(columnOptions.Column); + column.Style.Alignment.SetHorizontal(columnOptions.Alignment); + } + + internal static void SetColumnFormatByHeader(this IXLWorksheet ws, string columnHeader, string formatName) + { + var column = ws.ColumnByHeader(columnHeader); + if (column == null) throw new ArgumentOutOfRangeException(nameof(columnHeader)); + if (NumberFormatDictionary.TryGetValue(formatName, out var formatId)) + { + column.Style.NumberFormat.NumberFormatId = formatId; + } + else + { + column.Style.NumberFormat.Format = formatName; + } + } + + /// + /// Batches the source sequence into sized buckets. + /// + /// Type of elements in sequence. + /// The source sequence. + /// Size of buckets. + /// A sequence of equally sized buckets containing elements of the source collection. + /// + /// This operator uses deferred execution and streams its results (buckets and bucket content). + /// It is also identical to . + /// + //https://code.google.com/p/morelinq/source/browse/MoreLinq/Batch.cs + internal static IEnumerable> Batch(this IEnumerable source, int size) + { + return Batch(source, size, x => + { + var enumerable = x as IList ?? x.ToList(); + return enumerable; + }); + } + + /// + /// Batches the source sequence into sized buckets and applies a projection to each bucket. + /// + /// Type of elements in sequence. + /// Type of result returned by . + /// The source sequence. + /// Size of buckets. + /// The projection to apply to each bucket. + /// A sequence of projections on equally sized buckets containing elements of the source collection. + /// + /// This operator uses deferred execution and streams its results (buckets and bucket content). + /// It is also identical to . + /// + + internal static IEnumerable Batch(this IEnumerable source, int size, + Func, TResult> resultSelector) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (size <= 0) throw new ArgumentOutOfRangeException(nameof(size)); + if (resultSelector == null) throw new ArgumentNullException(nameof(resultSelector)); + return BatchImpl(source, size, resultSelector); + } + + private static IEnumerable BatchImpl(this IEnumerable source, int size, + Func, TResult> resultSelector) + { + TSource[] bucket = null; + var count = 0; + + foreach (var item in source) + { + if (bucket == null) + { + bucket = new TSource[size]; + } + + bucket[count++] = item; + + // The bucket is fully buffered before it's yielded + if (count != size) + { + continue; + } + + // Select is necessary so bucket contents are streamed too + yield return resultSelector(bucket.Select(x => x)); + + bucket = null; + count = 0; + } + + // Return the last bucket with all remaining elements + if (bucket != null && count > 0) + { + yield return resultSelector(bucket.Take(count)); + } + } + + } +} \ No newline at end of file diff --git a/src/Strata.Excel.Core/Export/XLColumnExtensions.cs b/src/Strata.Excel.Core/Export/XLColumnExtensions.cs new file mode 100644 index 0000000..6214049 --- /dev/null +++ b/src/Strata.Excel.Core/Export/XLColumnExtensions.cs @@ -0,0 +1,32 @@ +using ClosedXML.Excel; +using System.Collections.Generic; +using System.Linq; + +namespace Strata.Excel.Core.Export +{ + public static class XLColumnExtensions + { + /// + /// Add items to a dropdown list within a column. Items are saved in a hidden worksheet used to validate user input against. + /// + /// + /// Drop down list items + /// Name of hidden worksheet that holds data + /// Sort items in ascending order + public static void CreateDataValidation(this IXLColumn column, IEnumerable items, string hiddenWorksheetName, bool orderItems = true) + { + if (orderItems) + { + items = items.OrderBy(x => x).ToList(); + } + + var ws = column.Worksheet; + var wb = ws.Workbook; + var validationWorkSheet = wb.Worksheets.Add(hiddenWorksheetName); + var range = validationWorkSheet.Cell(1, 1).InsertData(items); + validationWorkSheet.Hide(); + + column.CreateDataValidation().List(range); + } + } +} diff --git a/src/Strata.Excel.Core/Import/ImportUtils.cs b/src/Strata.Excel.Core/Import/ImportUtils.cs new file mode 100644 index 0000000..5b02ad8 --- /dev/null +++ b/src/Strata.Excel.Core/Import/ImportUtils.cs @@ -0,0 +1,105 @@ +using ClosedXML.Excel; +using System; +using System.Collections.Generic; +using System.Data; +using System.Reflection; + +namespace Strata.Excel.Core.Import +{ + public static class ImportUtils + { + /// + /// Convert Worksheet to DataTable + /// + /// + /// + public static List GetDataFromExcel(this IXLWorksheet worksheet) + { + //Save the uploaded Excel file. + + + //Create a new DataTable. + var dt = new DataTable(); + + //Loop through the Worksheet rows. + bool firstRow = true; + foreach (IXLRow row in worksheet.Rows()) + { + // skip blank rows + if (!firstRow && row.IsEmpty()) continue; + + //Use the first row to add columns to DataTable. + if (firstRow) + { + foreach (IXLCell cell in row.Cells()) + { + if (cell.IsEmpty() || string.IsNullOrEmpty(cell.GetString())) { break; } + dt.Columns.Add(cell.GetString()); + } + firstRow = false; + } + else + { + int i = 0; + DataRow toInsert = dt.NewRow(); + foreach (IXLCell cell in row.Cells(1, dt.Columns.Count)) + { + toInsert[i] = cell.GetString(); + i++; + } + dt.Rows.Add(toInsert); + } + } + return ConvertDataTable(dt); + + } + + private static List ConvertDataTable(DataTable dt) + { + List data = new List(); + foreach (DataRow row in dt.Rows) + { + T item = GetItem(row); + data.Add(item); + } + return data; + } + private static T GetItem(DataRow dr) + { + Type temp = typeof(T); + T obj = Activator.CreateInstance(); + + foreach (DataColumn column in dr.Table.Columns) + { + var columnName = column.ColumnName.Replace(" ", ""); + foreach (PropertyInfo pro in temp.GetProperties()) + { + if (pro.Name.Equals(columnName, StringComparison.CurrentCultureIgnoreCase) + || pro.Name.Equals(column.ColumnName, StringComparison.CurrentCultureIgnoreCase)) + { + switch (pro.PropertyType.Name.ToLower()) + { + case "bool": + pro.SetValue(obj, Convert.ToBoolean(dr[column.ColumnName]), null); + break; + case "datetime": + pro.SetValue(obj, Convert.ToDateTime(dr[column.ColumnName]), null); + break; + case "double": + pro.SetValue(obj, Convert.ToDouble(dr[column.ColumnName]), null); + break; + case "int32": + pro.SetValue(obj, Convert.ToInt32(dr[column.ColumnName]), null); + break; + default: + pro.SetValue(obj, dr[column.ColumnName], null); + break; + } + } + } + } + return obj; + } + } + +} \ No newline at end of file diff --git a/src/Strata.Excel.Core/Strata.Excel.Core.csproj b/src/Strata.Excel.Core/Strata.Excel.Core.csproj new file mode 100644 index 0000000..bd216b8 --- /dev/null +++ b/src/Strata.Excel.Core/Strata.Excel.Core.csproj @@ -0,0 +1,16 @@ + + + + net5.0;netstandard2.0 + + + + + + + + + + + + diff --git a/src/Strata.Excel.TestUtilities/Strata.Excel.TestUtilities.csproj b/src/Strata.Excel.TestUtilities/Strata.Excel.TestUtilities.csproj new file mode 100644 index 0000000..736df67 --- /dev/null +++ b/src/Strata.Excel.TestUtilities/Strata.Excel.TestUtilities.csproj @@ -0,0 +1,12 @@ + + + + net5.0 + + + + + + + + diff --git a/src/Strata.Excel.TestUtilities/Utilities/ExcelDocsComparer.cs b/src/Strata.Excel.TestUtilities/Utilities/ExcelDocsComparer.cs new file mode 100644 index 0000000..d975f42 --- /dev/null +++ b/src/Strata.Excel.TestUtilities/Utilities/ExcelDocsComparer.cs @@ -0,0 +1,52 @@ +using ClosedXML.Excel; +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Packaging; + +namespace Strata.Excel.TestUtilities +{ + [ExcludeFromCodeCoverage] + public static class ExcelDocsComparer + { + public static bool Compare(string left, string right, out string message) + { + using (FileStream leftStream = File.OpenRead(left)) + using (FileStream rightStream = File.OpenRead(right)) + { + return Compare(leftStream, rightStream, out message); + } + } + + public static bool Compare(this Stream result, Stream expected, out string message) + { + using (Package leftPackage = Package.Open(result, FileMode.Open, FileAccess.Read)) + using (Package rightPackage = Package.Open(expected, FileMode.Open, FileAccess.Read)) + { + return PackageHelper.Compare(leftPackage, rightPackage, false, ExcludeMethod, out message); + } + } + + public static bool Compare(this IXLWorkbook result, IXLWorkbook expected, out string message) + { + using (var resultStream = new MemoryStream()) + using (var wbStream = new MemoryStream()) + { + expected.SaveAs(wbStream); + result.SaveAs(resultStream); + return wbStream.Compare(resultStream, out message); + } + } + + private static bool ExcludeMethod(Uri uri) + { + //Exclude service data + if (uri.OriginalString.EndsWith(".rels") || + uri.OriginalString.EndsWith(".psmdcp")) + { + return true; + } + return false; + } + } +} diff --git a/src/Strata.Excel.TestUtilities/Utilities/IXLExample.cs b/src/Strata.Excel.TestUtilities/Utilities/IXLExample.cs new file mode 100644 index 0000000..7155cf3 --- /dev/null +++ b/src/Strata.Excel.TestUtilities/Utilities/IXLExample.cs @@ -0,0 +1,7 @@ +namespace Strata.Excel.TestUtilities +{ + public interface IXLExample + { + void Create(string filePath); + } +} diff --git a/src/Strata.Excel.TestUtilities/Utilities/PackageHelper.cs b/src/Strata.Excel.TestUtilities/Utilities/PackageHelper.cs new file mode 100644 index 0000000..736dc04 --- /dev/null +++ b/src/Strata.Excel.TestUtilities/Utilities/PackageHelper.cs @@ -0,0 +1,533 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Packaging; +using System.Linq; +using System.Net.Mime; +using System.Text; +using System.Xml.Serialization; + +namespace Strata.Excel.TestUtilities +{ + [ExcludeFromCodeCoverage] + public static class PackageHelper + { + public static void WriteXmlPart(Package package, Uri uri, object content, XmlSerializer serializer) + { + if (package.PartExists(uri)) + { + package.DeletePart(uri); + } + PackagePart part = package.CreatePart(uri, MediaTypeNames.Text.Xml, CompressionOption.Fast); + using (Stream stream = part.GetStream()) + { + serializer.Serialize(stream, content); + } + } + + public static object ReadXmlPart(Package package, Uri uri, XmlSerializer serializer) + { + if (!package.PartExists(uri)) + { + throw new ApplicationException($"Package part '{uri.OriginalString}' doesn't exists!"); + } + PackagePart part = package.GetPart(uri); + using (Stream stream = part.GetStream()) + { + return serializer.Deserialize(stream); + } + } + + public static void WriteBinaryPart(Package package, Uri uri, Stream content) + { + if (package.PartExists(uri)) + { + package.DeletePart(uri); + } + PackagePart part = package.CreatePart(uri, MediaTypeNames.Application.Octet, CompressionOption.Fast); + using (Stream stream = part.GetStream()) + { + StreamHelper.StreamToStreamAppend(content, stream); + } + } + + /// + /// Returns part's stream + /// + /// + /// + /// + public static Stream ReadBinaryPart(Package package, Uri uri) + { + if (!package.PartExists(uri)) + { + throw new ApplicationException("Package part doesn't exists!"); + } + PackagePart part = package.GetPart(uri); + return part.GetStream(); + } + + public static void CopyPart(Uri uri, Package source, Package dest) + { + CopyPart(uri, source, dest, true); + } + + public static void CopyPart(Uri uri, Package source, Package dest, bool overwrite) + { + #region Check + + if (ReferenceEquals(uri, null)) + { + throw new ArgumentNullException(nameof(uri)); + } + if (ReferenceEquals(source, null)) + { + throw new ArgumentNullException(nameof(source)); + } + if (ReferenceEquals(dest, null)) + { + throw new ArgumentNullException(nameof(dest)); + } + + #endregion Check + + if (dest.PartExists(uri)) + { + if (!overwrite) + { + throw new ArgumentException("Specified part already exists", nameof(uri)); + } + dest.DeletePart(uri); + } + + PackagePart sourcePart = source.GetPart(uri); + PackagePart destPart = dest.CreatePart(uri, sourcePart.ContentType, sourcePart.CompressionOption); + + using (Stream sourceStream = sourcePart.GetStream()) + { + using (Stream destStream = destPart.GetStream()) + { + StreamHelper.StreamToStreamAppend(sourceStream, destStream); + } + } + } + + public static void WritePart(Package package, PackagePartDescriptor descriptor, T content, + Action serializeAction) + { + #region Check + + if (ReferenceEquals(package, null)) + { + throw new ArgumentNullException(nameof(package)); + } + if (ReferenceEquals(descriptor, null)) + { + throw new ArgumentNullException(nameof(descriptor)); + } + if (ReferenceEquals(serializeAction, null)) + { + throw new ArgumentNullException(nameof(serializeAction)); + } + + #endregion Check + + if (package.PartExists(descriptor.Uri)) + { + package.DeletePart(descriptor.Uri); + } + PackagePart part = package.CreatePart(descriptor.Uri, descriptor.ContentType, descriptor.CompressOption); + using (Stream stream = part.GetStream()) + { + serializeAction(stream, content); + } + } + + public static void WritePart(Package package, PackagePartDescriptor descriptor, Action serializeAction) + { + #region Check + + if (ReferenceEquals(package, null)) + { + throw new ArgumentNullException(nameof(package)); + } + if (ReferenceEquals(descriptor, null)) + { + throw new ArgumentNullException(nameof(descriptor)); + } + if (ReferenceEquals(serializeAction, null)) + { + throw new ArgumentNullException(nameof(serializeAction)); + } + + #endregion Check + + if (package.PartExists(descriptor.Uri)) + { + package.DeletePart(descriptor.Uri); + } + PackagePart part = package.CreatePart(descriptor.Uri, descriptor.ContentType, descriptor.CompressOption); + using (Stream stream = part.GetStream()) + { + serializeAction(stream); + } + } + + public static T ReadPart(Package package, Uri uri, Func deserializeFunc) + { + #region Check + + if (ReferenceEquals(package, null)) + { + throw new ArgumentNullException(nameof(package)); + } + if (ReferenceEquals(uri, null)) + { + throw new ArgumentNullException(nameof(uri)); + } + if (ReferenceEquals(deserializeFunc, null)) + { + throw new ArgumentNullException(nameof(deserializeFunc)); + } + + #endregion Check + + if (!package.PartExists(uri)) + { + throw new ApplicationException($"Package part '{uri.OriginalString}' doesn't exists!"); + } + PackagePart part = package.GetPart(uri); + using (Stream stream = part.GetStream()) + { + return deserializeFunc(stream); + } + } + + public static void ReadPart(Package package, Uri uri, Action deserializeAction) + { + #region Check + + if (ReferenceEquals(package, null)) + { + throw new ArgumentNullException(nameof(package)); + } + if (ReferenceEquals(uri, null)) + { + throw new ArgumentNullException(nameof(uri)); + } + if (ReferenceEquals(deserializeAction, null)) + { + throw new ArgumentNullException(nameof(deserializeAction)); + } + + #endregion Check + + if (!package.PartExists(uri)) + { + throw new ApplicationException($"Package part '{uri.OriginalString}' doesn't exists!"); + } + PackagePart part = package.GetPart(uri); + using (Stream stream = part.GetStream()) + { + deserializeAction(stream); + } + } + + public static bool TryReadPart(Package package, Uri uri, Action deserializeAction) + { + #region Check + + if (ReferenceEquals(package, null)) + { + throw new ArgumentNullException(nameof(package)); + } + if (ReferenceEquals(uri, null)) + { + throw new ArgumentNullException(nameof(uri)); + } + if (ReferenceEquals(deserializeAction, null)) + { + throw new ArgumentNullException(nameof(deserializeAction)); + } + + #endregion Check + + if (!package.PartExists(uri)) + { + return false; + } + PackagePart part = package.GetPart(uri); + using (Stream stream = part.GetStream()) + { + deserializeAction(stream); + } + return true; + } + + /// + /// Compare to packages by parts like streams + /// + /// + /// + /// + /// + /// + /// + public static bool Compare(Package left, Package right, bool compareToFirstDifference, out string message) + { + return Compare(left, right, compareToFirstDifference, null, out message); + } + + /// + /// Compare to packages by parts like streams + /// + /// + /// + /// + /// + /// + /// + public static bool Compare(Package left, Package right, bool compareToFirstDifference, + Func excludeMethod, out string message) + { + #region Check + + if (left == null) + { + throw new ArgumentNullException(nameof(left)); + } + if (right == null) + { + throw new ArgumentNullException(nameof(right)); + } + + #endregion Check + + excludeMethod = excludeMethod ?? (uri => false); + PackagePartCollection leftParts = left.GetParts(); + PackagePartCollection rightParts = right.GetParts(); + + var pairs = new Dictionary(); + foreach (PackagePart part in leftParts) + { + if (excludeMethod(part.Uri)) + { + continue; + } + pairs.Add(part.Uri, new PartPair(part.Uri, CompareStatus.OnlyOnLeft)); + } + foreach (PackagePart part in rightParts) + { + if (excludeMethod(part.Uri)) + { + continue; + } + if (pairs.TryGetValue(part.Uri, out PartPair pair)) + { + pair.Status = CompareStatus.Equal; + } + else + { + pairs.Add(part.Uri, new PartPair(part.Uri, CompareStatus.OnlyOnRight)); + } + } + + if (compareToFirstDifference && pairs.Any(pair => pair.Value.Status != CompareStatus.Equal)) + { + goto EXIT; + } + + foreach (PartPair pair in pairs.Values) + { + if (pair.Status != CompareStatus.Equal) + { + continue; + } + var leftPart = left.GetPart(pair.Uri); + var rightPart = right.GetPart(pair.Uri); + using (Stream leftPackagePartStream = leftPart.GetStream(FileMode.Open, FileAccess.Read)) + using (Stream rightPackagePartStream = rightPart.GetStream(FileMode.Open, FileAccess.Read)) + using (var leftMemoryStream = new MemoryStream()) + using (var rightMemoryStream = new MemoryStream()) + { + leftPackagePartStream.CopyTo(leftMemoryStream); + rightPackagePartStream.CopyTo(rightMemoryStream); + + leftMemoryStream.Seek(0, SeekOrigin.Begin); + rightMemoryStream.Seek(0, SeekOrigin.Begin); + + bool stripColumnWidthsFromSheet = TestHelper.StripColumnWidths && + leftPart.ContentType == @"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" && + rightPart.ContentType == @"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"; + + var tuple1 = new Tuple(pair.Uri, leftMemoryStream); + var tuple2 = new Tuple(pair.Uri, rightMemoryStream); + + if (!StreamHelper.Compare(tuple1, tuple2, stripColumnWidthsFromSheet)) + { + pair.Status = CompareStatus.NonEqual; + if (compareToFirstDifference) + { + goto EXIT; + } + } + } + } + + EXIT: + List sortedPairs = pairs.Values.ToList(); + sortedPairs.Sort((one, other) => one.Uri.OriginalString.CompareTo(other.Uri.OriginalString)); + var sbuilder = new StringBuilder(); + foreach (PartPair pair in sortedPairs) + { + if (pair.Status == CompareStatus.Equal) + { + continue; + } + sbuilder.AppendFormat("{0} :{1}", pair.Uri, pair.Status); + sbuilder.AppendLine(); + } + message = sbuilder.ToString(); + return message.Length == 0; + } + + #region Nested type: PackagePartDescriptor + + public sealed class PackagePartDescriptor + { + #region Private fields + + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private readonly CompressionOption _compressOption; + + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private readonly string _contentType; + + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private readonly Uri _uri; + + #endregion Private fields + + #region Constructor + + /// + /// Instance constructor + /// + /// Part uri + /// Content type from + /// + public PackagePartDescriptor(Uri uri, string contentType, CompressionOption compressOption) + { + #region Check + + if (ReferenceEquals(uri, null)) + { + throw new ArgumentNullException(nameof(uri)); + } + if (string.IsNullOrEmpty(contentType)) + { + throw new ArgumentNullException(nameof(contentType)); + } + + #endregion Check + + _uri = uri; + _contentType = contentType; + _compressOption = compressOption; + } + + #endregion Constructor + + #region Public properties + + public Uri Uri + { + [DebuggerStepThrough] + get => _uri; + } + + public string ContentType + { + [DebuggerStepThrough] + get => _contentType; + } + + public CompressionOption CompressOption + { + [DebuggerStepThrough] + get { return _compressOption; } + } + + #endregion Public properties + + #region Public methods + + public override string ToString() => $"Uri:{_uri} ContentType: {_contentType}, Compression: {_compressOption}"; + + #endregion Public methods + } + + #endregion Nested type: PackagePartDescriptor + + #region Nested type: CompareStatus + + private enum CompareStatus + { + OnlyOnLeft, + OnlyOnRight, + Equal, + NonEqual + } + + #endregion Nested type: CompareStatus + + #region Nested type: PartPair + + private sealed class PartPair + { + #region Private fields + + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private readonly Uri _uri; + + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private CompareStatus _status; + + #endregion Private fields + + #region Constructor + + public PartPair(Uri uri, CompareStatus status) + { + _uri = uri; + _status = status; + } + + #endregion Constructor + + #region Public properties + + public Uri Uri + { + [DebuggerStepThrough] + get { return _uri; } + } + + public CompareStatus Status + { + [DebuggerStepThrough] + get { return _status; } + [DebuggerStepThrough] + set { _status = value; } + } + + #endregion Public properties + } + + #endregion Nested type: PartPair + + //-- + } +} diff --git a/src/Strata.Excel.TestUtilities/Utilities/ResourceFileExtractor.cs b/src/Strata.Excel.TestUtilities/Utilities/ResourceFileExtractor.cs new file mode 100644 index 0000000..a09ac14 --- /dev/null +++ b/src/Strata.Excel.TestUtilities/Utilities/ResourceFileExtractor.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Reflection; + +namespace Strata.Excel.TestUtilities +{ + /// + /// Summary description for ResourceFileExtractor. + /// + [ExcludeFromCodeCoverage] + public sealed class ResourceFileExtractor + { + #region Static + + #region Private fields + + private static readonly IDictionary extractors = new ConcurrentDictionary(); + + #endregion Private fields + + #region Public properties + + /// Instance of resource extractor for executing assembly + public static ResourceFileExtractor Instance + { + get + { + var _assembly = Assembly.GetCallingAssembly(); + var _key = _assembly.GetName().FullName; + if (extractors.TryGetValue(_key, out var extractor) || + extractors.TryGetValue(_key, out extractor)) return extractor; + + extractor = new ResourceFileExtractor(_assembly, true, null); + extractors.Add(_key, extractor); + + return extractor; + } + } + + #endregion Public properties + + #endregion Static + + #region Private fields + + //private readonly Assembly m_assembly; + private readonly ResourceFileExtractor m_baseExtractor; + + //private bool m_isStatic; + //private string ResourceFilePath { get; } + + #endregion Private fields + + #region Constructors + + /// + /// Create instance + /// + /// ResourceFilePath in assembly. Example: .Properties.Scripts. + /// + public ResourceFileExtractor(string resourceFilePath, ResourceFileExtractor baseExtractor) + : this(Assembly.GetCallingAssembly(), baseExtractor) + { + ResourceFilePath = resourceFilePath; + } + + /// + /// Create instance + /// + /// + public ResourceFileExtractor(ResourceFileExtractor baseExtractor) + : this(Assembly.GetCallingAssembly(), baseExtractor) + { + } + + /// + /// Create instance + /// + /// ResourceFilePath in assembly. Example: .Properties.Scripts. + public ResourceFileExtractor(string resourcePath) + : this(Assembly.GetCallingAssembly(), resourcePath) + { + } + + /// + /// Instance constructor + /// + /// + /// + public ResourceFileExtractor(Assembly assembly, string resourcePath) + : this(assembly ?? Assembly.GetCallingAssembly()) + { + ResourceFilePath = resourcePath; + } + + /// + /// Instance constructor + /// + public ResourceFileExtractor() + : this(Assembly.GetCallingAssembly()) + { + } + + /// + /// Instance constructor + /// + /// + public ResourceFileExtractor(Assembly assembly) + : this(assembly ?? Assembly.GetCallingAssembly(), (ResourceFileExtractor)null) + { + } + + /// + /// Instance constructor + /// + /// + /// + public ResourceFileExtractor(Assembly assembly, ResourceFileExtractor baseExtractor) + : this(assembly ?? Assembly.GetCallingAssembly(), false, baseExtractor) + { + } + + /// + /// Instance constructor + /// + /// + /// + /// + /// Argument is null. + private ResourceFileExtractor(Assembly assembly, bool isStatic, ResourceFileExtractor baseExtractor) + { + #region Check + + if (assembly is null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + #endregion Check + + Assembly = assembly; + m_baseExtractor = baseExtractor; + AssemblyName = Assembly.GetName().Name; + IsStatic = isStatic; + ResourceFilePath = ".Resources."; + } + + #endregion Constructors + + #region Public properties + + /// Work assembly + public Assembly Assembly { get; } + + /// Work assembly name + public string AssemblyName { get; } + + /// + /// Path to read resource files. Example: .Resources.Upgrades. + /// + public string ResourceFilePath { get; } + + public bool IsStatic { get; set; } + + public IEnumerable GetFileNames(Func predicate = null) + { + predicate = predicate ?? (s => true); + + var _path = AssemblyName + ResourceFilePath; + foreach (string _resourceName in Assembly.GetManifestResourceNames()) + { + if (_resourceName.StartsWith(_path) && predicate(_resourceName)) + { + yield return _resourceName.Replace(_path, string.Empty); + } + } + } + + #endregion Public properties + + #region Public methods + + public string ReadFileFromResource(string fileName) + { + var _stream = ReadFileFromResourceToStream(fileName); + string _result; + var sr = new StreamReader(_stream); + try + { + _result = sr.ReadToEnd(); + } + finally + { + sr.Close(); + } + return _result; + } + + public string ReadFileFromResourceFormat(string fileName, params object[] formatArgs) + { + return string.Format(ReadFileFromResource(fileName), formatArgs); + } + + /// + /// Read file in current assembly by specific path + /// + /// Specific path + /// Read file name + /// + public string ReadSpecificFileFromResource(string specificPath, string fileName) + { + ResourceFileExtractor _ext = new ResourceFileExtractor(Assembly, specificPath); + return _ext.ReadFileFromResource(fileName); + } + + /// + /// Read file in current assembly by specific file name + /// + /// + /// + /// ApplicationException. + public Stream ReadFileFromResourceToStream(string fileName) + { + var _nameResFile = AssemblyName + ResourceFilePath + fileName; + var _stream = Assembly.GetManifestResourceStream(_nameResFile); + + #region Not found + + if (_stream is null) + { + #region Get from base extractor + + if (!(m_baseExtractor is null)) + { + return m_baseExtractor.ReadFileFromResourceToStream(fileName); + } + + #endregion Get from base extractor + + throw new ArgumentException("Can't find resource file " + _nameResFile, nameof(fileName)); + } + + #endregion Not found + + return _stream; + } + + #endregion Public methods + } +} diff --git a/src/Strata.Excel.TestUtilities/Utilities/StreamHelper.cs b/src/Strata.Excel.TestUtilities/Utilities/StreamHelper.cs new file mode 100644 index 0000000..417c049 --- /dev/null +++ b/src/Strata.Excel.TestUtilities/Utilities/StreamHelper.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Strata.Excel.TestUtilities +{ + /// + /// Help methods for work with streams + /// + [ExcludeFromCodeCoverage] + public static class StreamHelper + { + /// + /// Convert stream to byte array + /// + /// Stream + /// Byte array + public static byte[] StreamToArray(Stream pStream) + { + long iLength = pStream.Length; + var bytes = new byte[iLength]; + for (int i = 0; i < iLength; i++) + { + bytes[i] = (byte)pStream.ReadByte(); + } + pStream.Close(); + return bytes; + } + + /// + /// Convert byte array to stream + /// + /// Byte array + /// Open stream + /// + public static Stream ArrayToStreamAppend(byte[] pBynaryArray, Stream pStream) + { + #region Check params + + if (ReferenceEquals(pBynaryArray, null)) + { + throw new ArgumentNullException(nameof(pBynaryArray)); + } + if (ReferenceEquals(pStream, null)) + { + throw new ArgumentNullException(nameof(pStream)); + } + if (!pStream.CanWrite) + { + throw new ArgumentException("Can't write to stream", nameof(pStream)); + } + + #endregion Check params + + foreach (byte b in pBynaryArray) + { + pStream.WriteByte(b); + } + return pStream; + } + + public static void StreamToStreamAppend(Stream streamIn, Stream streamToWrite) + { + StreamToStreamAppend(streamIn, streamToWrite, 0); + } + + public static void StreamToStreamAppend(Stream streamIn, Stream streamToWrite, long dataLength) + { + #region Check params + + if (ReferenceEquals(streamIn, null)) + { + throw new ArgumentNullException(nameof(streamIn)); + } + if (ReferenceEquals(streamToWrite, null)) + { + throw new ArgumentNullException(nameof(streamToWrite)); + } + if (!streamIn.CanRead) + { + throw new ArgumentException("Can't read from stream", nameof(streamIn)); + } + if (!streamToWrite.CanWrite) + { + throw new ArgumentException("Can't write to stream", nameof(streamToWrite)); + } + + #endregion Check params + + var buf = new byte[512]; + long length; + if (dataLength == 0) + { + length = streamIn.Length - streamIn.Position; + } + else + { + length = dataLength; + } + long rest = length; + while (rest > 0) + { + int len1 = streamIn.Read(buf, 0, rest >= 512 ? 512 : (int)rest); + streamToWrite.Write(buf, 0, len1); + rest -= len1; + } + } + + /// + /// Compare two streams by converting them to strings and comparing the strings + /// + /// + /// + /// /// + /// + public static bool Compare(Tuple tuple1, Tuple tuple2, bool stripColumnWidths) + { + #region Check + + if (tuple1 == null || tuple1.Item1 == null || tuple1.Item2 == null) + { + throw new ArgumentNullException(nameof(tuple1)); + } + if (tuple2 == null || tuple2.Item1 == null || tuple2.Item2 == null) + { + throw new ArgumentNullException(nameof(tuple2)); + } + if (tuple1.Item2.Position != 0) + { + throw new ArgumentException("Must be in position 0", nameof(tuple1)); + } + if (tuple2.Item2.Position != 0) + { + throw new ArgumentException("Must be in position 0", nameof(tuple2)); + } + + #endregion Check + + var stringOne = new StreamReader(tuple1.Item2).ReadToEnd().RemoveIgnoredParts(tuple1.Item1, stripColumnWidths, ignoreGuids: true); + var stringOther = new StreamReader(tuple2.Item2).ReadToEnd().RemoveIgnoredParts(tuple2.Item1, stripColumnWidths, ignoreGuids: true); + return stringOne == stringOther; + } + + private static string RemoveIgnoredParts(this string s, Uri uri, bool ignoreColumnWidths, bool ignoreGuids) + { + s = uriSpecificIgnores.Where(p => p.Key.Equals(uri.OriginalString)).Aggregate(s, (current, pair) => pair.Value.Replace(current, "")); + + // Collapse empty xml elements + s = emptyXmlElementRegex.Replace(s, "<$1 />"); + + if (ignoreColumnWidths) + s = RemoveColumnWidths(s); + + if (ignoreGuids) + s = RemoveGuids(s); + + return s; + } + + private static IEnumerable> uriSpecificIgnores = new List>() + { + // Remove dcterms elements + new KeyValuePair("/docProps/core.xml", new Regex(@"", RegexOptions.Compiled)) + }; + + private static Regex emptyXmlElementRegex = new Regex(@"<([\w:]+)><\/\1>", RegexOptions.Compiled); + private static Regex columnRegex = new Regex("", RegexOptions.Compiled); + private static Regex widthRegex = new Regex("width=\"\\d+(\\.\\d+)?\"\\s+", RegexOptions.Compiled); + + private static string RemoveColumnWidths(string s) + { + var replacements = new Dictionary(); + + foreach (var m in columnRegex.Matches(s).OfType()) + { + var original = m.Groups[0].Value; + var replacement = widthRegex.Replace(original, ""); + replacements.Add(original, replacement); + } + + return replacements.Aggregate(s, (current, r) => current.Replace(r.Key, r.Value)); + } + + private static Regex guidRegex = new Regex(@"{[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}}", RegexOptions.Compiled | RegexOptions.Multiline); + + private static string RemoveGuids(string s) => guidRegex.Replace(s, m => string.Empty); + } +} diff --git a/src/Strata.Excel.TestUtilities/Utilities/TestHelper.cs b/src/Strata.Excel.TestUtilities/Utilities/TestHelper.cs new file mode 100644 index 0000000..9cc2504 --- /dev/null +++ b/src/Strata.Excel.TestUtilities/Utilities/TestHelper.cs @@ -0,0 +1,145 @@ +using ClosedXML.Excel; +using FluentAssertions; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; + +namespace Strata.Excel.TestUtilities +{ + [ExcludeFromCodeCoverage] + public static class TestHelper + { + public static string CurrencySymbol => Thread.CurrentThread.CurrentCulture.NumberFormat.CurrencySymbol; + + //Note: Run example tests parameters + public static string TestsOutputDirectory => Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), "Generated"); + + public const string ActualTestResultPostFix = ""; + public static readonly string ExampleTestsOutputDirectory = Path.Combine(TestsOutputDirectory, "Examples"); + + private const bool CompareWithResources = true; + + private static readonly ResourceFileExtractor _extractor = new ResourceFileExtractor(".Resource."); + + public static void SaveWorkbook(XLWorkbook workbook, params string[] fileNameParts) + { + workbook.SaveAs(Path.Combine(new string[] { TestsOutputDirectory }.Concat(fileNameParts).ToArray()), true); + } + + // Because different fonts are installed on Unix, + // the columns widths after AdjustToContents() will + // cause the tests to fail. + // Therefore we ignore the width attribute when running on Unix + public static bool StripColumnWidths => IsRunningOnUnix; + + public static bool IsRunningOnUnix + { + get + { + var p = (int)Environment.OSVersion.Platform; + return ((p == 4) || (p == 6) || (p == 128)); + } + } + + public static void RunTestExample(string filePartName, bool evaluateFormulae = false) + where T : IXLExample, new() + { + // Make sure tests run on a deterministic culture + Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("en-US"); + + var example = new T(); + var pathParts = filePartName.Split(new char[] { '\\' }); + var filePath1 = Path.Combine(new List() { ExampleTestsOutputDirectory }.Concat(pathParts).ToArray()); + + var extension = Path.GetExtension(filePath1); + var directory = Path.GetDirectoryName(filePath1); + + var fileName = Path.GetFileNameWithoutExtension(filePath1); + fileName += ActualTestResultPostFix; + fileName = Path.ChangeExtension(fileName, extension); + + filePath1 = Path.Combine(directory, "z" + fileName); + var filePath2 = Path.Combine(directory, fileName); + + //Run test + example.Create(filePath1); + using (var wb = new XLWorkbook(filePath1)) + wb.SaveAs(filePath2, validate: true, evaluateFormulae); + + // Also load from template and save it again - but not necessary to test against reference file + // We're just testing that it can save. + using (var ms = new MemoryStream()) + using (var wb = XLWorkbook.OpenFromTemplate(filePath1)) + wb.SaveAs(ms, validate: true, evaluateFormulae); + + var resourcePath = "Examples." + filePartName.Replace('\\', '.').TrimStart('.'); + using (var streamExpected = _extractor.ReadFileFromResourceToStream(resourcePath)) + using (var streamActual = File.OpenRead(filePath2)) + { + var success = ExcelDocsComparer.Compare(streamActual, streamExpected, out string message); + var formattedMessage = + $"Actual file '{filePath2}' is different than the expected file '{resourcePath}'. The difference is: '{message}'"; + success.Should().BeTrue(formattedMessage); + } + } + + public static void CreateAndCompare(Func workbookGenerator, string referenceResource, bool evaluateFormulae = false) + { + Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("en-US"); + + var pathParts = referenceResource.Split(new char[] { '\\' }); + var filePath1 = Path.Combine(new List() { TestsOutputDirectory }.Concat(pathParts).ToArray()); + + var extension = Path.GetExtension(filePath1); + var directory = Path.GetDirectoryName(filePath1); + + var fileName = Path.GetFileNameWithoutExtension(filePath1); + fileName += ActualTestResultPostFix; + fileName = Path.ChangeExtension(fileName, extension); + + var filePath2 = Path.Combine(directory, fileName); + + using (var wb = workbookGenerator.Invoke()) + wb.SaveAs(filePath2, true, evaluateFormulae); + + var resourcePath = referenceResource.Replace('\\', '.').TrimStart('.'); + using (var streamExpected = _extractor.ReadFileFromResourceToStream(resourcePath)) + using (var streamActual = File.OpenRead(filePath2)) + { + var success = ExcelDocsComparer.Compare(streamActual, streamExpected, out string message); + var formattedMessage = + $"Actual file '{filePath2}' is different than the expected file '{resourcePath}'. The difference is: '{message}'"; + success.Should().BeTrue(formattedMessage); + } + } + + public static string GetResourcePath(string filePartName) + { + return filePartName.Replace('\\', '.').TrimStart('.'); + } + + public static Stream GetStreamFromResource(string resourcePath) + { + return _extractor.ReadFileFromResourceToStream(resourcePath); + } + + public static void LoadFile(string filePartName) + { + IXLWorkbook wb; + using var stream = GetStreamFromResource(GetResourcePath(filePartName)); + Action action = () => + { + wb = new XLWorkbook(stream); + }; + action.Should().NotThrow($"Unable to load resource {filePartName}"); + } + + public static IEnumerable ListResourceFiles(Func predicate = null) + { + return _extractor.GetFileNames(predicate); + } + } +} diff --git a/tests/Strata.Excel.Core.Test.Unit/ExcelExportTests/TestExcelExport.cs b/tests/Strata.Excel.Core.Test.Unit/ExcelExportTests/TestExcelExport.cs new file mode 100644 index 0000000..f7b8b8b --- /dev/null +++ b/tests/Strata.Excel.Core.Test.Unit/ExcelExportTests/TestExcelExport.cs @@ -0,0 +1,160 @@ +using ClosedXML.Excel; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Strata.Excel.Core.Import; +using Strata.Excel.TestUtilities; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using static Strata.Excel.Core.ExportUtils; + +namespace Strata.Excel.Core.Test.Unit.ExcelExportTests +{ + [TestFixture] + public class TestExcelExport + { + [OneTimeSetUp] + public void RunBeforeAnyTests() + { + Environment.CurrentDirectory = TestContext.CurrentContext.TestDirectory; + // or identically under the hoods + Directory.SetCurrentDirectory(TestContext.CurrentContext.TestDirectory); + } + + private static readonly ResourceFileExtractor _extractor = new ResourceFileExtractor(".ExpectedResults."); + private static readonly ResourceFileExtractor _dataExtractor = new ResourceFileExtractor(".ExcelExportTests."); + + [Test, Ignore("File and stream do not match")] + public void TestCreateExcelWorkbook() + { + var currentDir = Directory.GetCurrentDirectory(); + var jsonData = _dataExtractor.ReadFileFromResource("data.json"); + var data = JsonConvert.DeserializeObject>(jsonData) + .OrderBy(t => t.OrgPin) + .ThenBy(t => t.DatabaseFriendlyName) + .ThenBy(t => t.Description); + var options = new ExportOptions + { + EmptyMessage = "No Mappings" + }; + options.AddColumnOptions("Confidence Score", NumberFormatId.ZeroPercent, XLAlignmentHorizontalValues.Right); + options.AddColumnOptions("Date Mapped", NumberFormatId.ShortDateSlash, XLAlignmentHorizontalValues.Right); + + var expected = $@"{TestContext.CurrentContext.Test.Name}.xlsx"; + var wb = CreateExcelWorkbook(data, options); +#pragma warning disable S125 // Sections of code should not be commented out + // wb.SaveAs(Path.Combine(@"C:\Git\excel.core\tests\Strata.Excel.Core.Test.Unit\ExcelExportTests\", expected)); +#pragma warning restore S125 // Sections of code should not be commented + + // assert + wb.Worksheets.Should().HaveCount(1); + var ws = wb.Worksheet(1); + ws.Should().NotBeNull(); + + using (var expectedStream = _extractor.ReadFileFromResourceToStream(expected)) + using (var actualStream = new MemoryStream()) + { + wb.SaveAs(actualStream); + actualStream.Compare(expectedStream, out var message).Should().BeTrue(message); + } + + } + + [Test, Ignore("File and stream do not match")] + public void TestCreateExcelWorkbookWithoutHumanizedHeadings() + { + var currentDir = Directory.GetCurrentDirectory(); + var jsonData = _dataExtractor.ReadFileFromResource("data.json"); + var data = JsonConvert.DeserializeObject>(jsonData) + .OrderBy(t => t.OrgPin) + .ThenBy(t => t.DatabaseFriendlyName) + .ThenBy(t => t.Description); + var options = new ExportOptions + { + EmptyMessage = "No Mappings", + HumanizeHeading = false + }; + options.AddColumnOptions("ConfidenceScore", NumberFormatId.ZeroPercent, XLAlignmentHorizontalValues.Right); + options.AddColumnOptions("DateMapped", NumberFormatId.ShortDateSlash, XLAlignmentHorizontalValues.Right); + + var expected = $@"{TestContext.CurrentContext.Test.Name}.xlsx"; + var wb = CreateExcelWorkbook(data, options); +#pragma warning disable S125 // Sections of code should not be commented out + //wb.SaveAs(Path.Combine(@"C:\Git\excel.core\tests\Strata.Excel.Core.Test.Unit\ExcelExportTests\", expected)); +#pragma warning restore S125 // Sections of code should not be commented + + // assert + wb.Worksheets.Should().HaveCount(1); + var ws = wb.Worksheet(1); + ws.Should().NotBeNull(); + + using (var expectedStream = _extractor.ReadFileFromResourceToStream(expected)) + using (var actualStream = new MemoryStream()) + { + wb.SaveAs(actualStream); + actualStream.Compare(expectedStream, out var message).Should().BeTrue(message); + } + + } + + [Test, Ignore("This keeps requiring that we update the stream on the test")] + public void TestCreateExcelWorkbookWithTitlePage() + { + var currentDir = Directory.GetCurrentDirectory(); + var jsonData = _dataExtractor.ReadFileFromResource("data.json"); + var data = JsonConvert.DeserializeObject>(jsonData) + .OrderBy(t => t.OrgPin) + .ThenBy(t => t.DatabaseFriendlyName) + .ThenBy(t => t.Description); + var options = new ExportOptions + { + Title = "Account Mappgings", + SubTitle = "Strata Decision Technology®", + EmptyMessage = "No Mappings" + }; + options.AddColumnOptions("Confidence Score", NumberFormatId.ZeroPercent, XLAlignmentHorizontalValues.Right); + options.AddColumnOptions("Date Mapped", NumberFormatId.ShortDateSlash, XLAlignmentHorizontalValues.Right); + + var expected = $@"{TestContext.CurrentContext.Test.Name}.xlsx"; + var wb = CreateExcelWorkbook(data, options); +#pragma warning disable S125 // Sections of code should not be commented out + //wb.SaveAs(Path.Combine(@"C:\Git\excel.core\tests\Strata.Excel.Core.Test.Unit\ExcelExportTests\", expected)); +#pragma warning restore S125 // Sections of code should not be commented + + // assert + wb.Worksheets.Should().HaveCount(2); + var ws = wb.Worksheet(2); + ws.Should().NotBeNull(); + ws.GetDataFromExcel().Should().HaveCount(data.Count()); + + using (var expectedStream = _extractor.ReadFileFromResourceToStream(expected)) + using (var actualStream = new MemoryStream()) + { + wb.SaveAs(actualStream); + actualStream.Compare(expectedStream, out var message).Should().BeTrue(message); + } + + } + + internal class TestData + { + public string OrgPin { get; set; } + public string DatabaseName { get; set; } + public string DatabaseFriendlyName { get; set; } + public string SphAccountRollupStatement { get; set; } + public string SphAccountRollupCategory { get; set; } + public string SphAccountRollupLineItem { get; set; } + public string SphAccountRollupName { get; set; } + public double ConfidenceScore { get; set; } + public string DateMapped { get; set; } + public int AccountId { get; set; } + public string AccountCode { get; set; } + public string Description { get; set; } + public string GLRollup { get; set; } + public string OBDollarsFinancialReporting { get; set; } + public string DSSAccountRollup1Name { get; set; } + } + } +} diff --git a/tests/Strata.Excel.Core.Test.Unit/ExcelExportTests/data.json b/tests/Strata.Excel.Core.Test.Unit/ExcelExportTests/data.json new file mode 100644 index 0000000..266ba3b --- /dev/null +++ b/tests/Strata.Excel.Core.Test.Unit/ExcelExportTests/data.json @@ -0,0 +1,342 @@ +[ + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Assets - Current", + "SphAccountRollupLineItem": "Accounts Receivable - Other", + "SphAccountRollupName": "Balance Sheet - Assets - Current - Accounts Receivable - Other", + "ConfidenceScore": 0.8767915097336801, + "DateMapped": "7/26/2022", + "AccountId": 420, + "AccountCode": "105219", + "Description": "340B DIFFERENTIAL RECEIVABLE", + "GLRollup": "SKCURRENTASSETS", + "OBDollarsFinancialReporting": "Bal - CURRENT ASSETS - Other current assets", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Assets - Inventory", + "SphAccountRollupLineItem": "Inventory", + "SphAccountRollupName": "Balance Sheet - Assets - Inventory - Inventory", + "ConfidenceScore": 0.9104504962709786, + "DateMapped": "7/26/2022", + "AccountId": 496, + "AccountCode": "110024", + "Description": "340B RETAIL INVENTORY", + "GLRollup": "SKCURRENTASSETS", + "OBDollarsFinancialReporting": "Bal - CURRENT ASSETS - Inventory", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Income Statement", + "SphAccountRollupCategory": "Revenue - Patient - Gross", + "SphAccountRollupLineItem": "Revenue - Outpatient", + "SphAccountRollupName": "Income Statement - Revenue - Patient - Gross - Revenue - Outpatient", + "ConfidenceScore": 0.9840537253695426, + "DateMapped": "7/26/2022", + "AccountId": 2359, + "AccountCode": "420800", + "Description": "340B RETAIL REVENUE", + "GLRollup": "OUTPATIENT REVENUE", + "OBDollarsFinancialReporting": "IS - REVENUE: - Outpatient revenue", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Income Statement", + "SphAccountRollupCategory": "Revenue - Other - Operating", + "SphAccountRollupLineItem": "Other Operating Revenue", + "SphAccountRollupName": "Income Statement - Revenue - Other - Operating - Other Operating Revenue", + "ConfidenceScore": 0.8907208071926644, + "DateMapped": "7/26/2022", + "AccountId": 2305, + "AccountCode": "575506", + "Description": "340B RETAIL REVENUE", + "GLRollup": "OTHER REVENUE", + "OBDollarsFinancialReporting": "IS - Other Operating Rev - Other operating revenue", + "DSSAccountRollup1Name": "Other Operating Revenue" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Income Statement", + "SphAccountRollupCategory": "Revenue - Other - Operating", + "SphAccountRollupLineItem": "Other Operating Revenue", + "SphAccountRollupName": "Income Statement - Revenue - Other - Operating - Other Operating Revenue", + "ConfidenceScore": 0.8907208071926644, + "DateMapped": "7/26/2022", + "AccountId": 2302, + "AccountCode": "575030", + "Description": "340B RETAIL REVENUE", + "GLRollup": "OTHER REVENUE", + "OBDollarsFinancialReporting": "IS - Other Operating Rev - Other operating revenue", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Liabilities - Current", + "SphAccountRollupLineItem": "Accrued Employee Compensation and Benefits", + "SphAccountRollupName": "Balance Sheet - Liabilities - Current - Accrued Employee Compensation and Benefits", + "ConfidenceScore": 0.39884468761403535, + "DateMapped": "7/26/2022", + "AccountId": 1302, + "AccountCode": "244130", + "Description": "401K CONTR CATCH UP OVER 50", + "GLRollup": "SKCURRENTLIABILITIES", + "OBDollarsFinancialReporting": "Bal - CURRENT LIABILITIES - Other current liabilities", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Liabilities - Current", + "SphAccountRollupLineItem": "Accrued Employee Compensation and Benefits", + "SphAccountRollupName": "Balance Sheet - Liabilities - Current - Accrued Employee Compensation and Benefits", + "ConfidenceScore": 0.7123256655440802, + "DateMapped": "7/26/2022", + "AccountId": 1301, + "AccountCode": "244126", + "Description": "401K CONTR MATCH PBL", + "GLRollup": "SKCURRENTLIABILITIES", + "OBDollarsFinancialReporting": "Bal - CURRENT LIABILITIES - Other current liabilities", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Liabilities - Current", + "SphAccountRollupLineItem": "Accrued Employee Compensation and Benefits", + "SphAccountRollupName": "Balance Sheet - Liabilities - Current - Accrued Employee Compensation and Benefits", + "ConfidenceScore": 0.530717787014235, + "DateMapped": "7/26/2022", + "AccountId": 1304, + "AccountCode": "244140", + "Description": "401K CONTR MILITARY MAKE UP", + "GLRollup": "SKCURRENTLIABILITIES", + "OBDollarsFinancialReporting": "Bal - CURRENT LIABILITIES - Other current liabilities", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Liabilities - Current", + "SphAccountRollupLineItem": "Accrued Employee Compensation and Benefits", + "SphAccountRollupName": "Balance Sheet - Liabilities - Current - Accrued Employee Compensation and Benefits", + "ConfidenceScore": 0.7266931053978312, + "DateMapped": "7/26/2022", + "AccountId": 1303, + "AccountCode": "244135", + "Description": "401K CONTR NO MATCH", + "GLRollup": "SKCURRENTLIABILITIES", + "OBDollarsFinancialReporting": "Bal - CURRENT LIABILITIES - Other current liabilities", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Income Statement", + "SphAccountRollupCategory": "Revenue - Other - Nonoperating Items", + "SphAccountRollupLineItem": "Contributions", + "SphAccountRollupName": "Income Statement - Revenue - Other - Nonoperating Items - Contributions", + "ConfidenceScore": 0.5031995471555109, + "DateMapped": "7/26/2022", + "AccountId": 1300, + "AccountCode": "244125", + "Description": "401K CONTRIBUTIONS", + "GLRollup": "SKCURRENTLIABILITIES", + "OBDollarsFinancialReporting": "Bal - CURRENT LIABILITIES - Other current liabilities", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Income Statement", + "SphAccountRollupCategory": "Expenses - Operating", + "SphAccountRollupLineItem": "Supplies", + "SphAccountRollupName": "Income Statement - Expenses - Operating - Supplies", + "ConfidenceScore": 0.4405172973818624, + "DateMapped": "7/26/2022", + "AccountId": 2799, + "AccountCode": "646009", + "Description": "A2CL BLOOD ALLOCATION", + "GLRollup": "SUPPLIES", + "OBDollarsFinancialReporting": "IS - EXPENSES: - Medical supplies", + "DSSAccountRollup1Name": "Medical Supplies" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Income Statement", + "SphAccountRollupCategory": "Revenue - Other - Operating", + "SphAccountRollupLineItem": "Other Operating Revenue", + "SphAccountRollupName": "Income Statement - Revenue - Other - Operating - Other Operating Revenue", + "ConfidenceScore": 0.9150939588019593, + "DateMapped": "7/26/2022", + "AccountId": 2287, + "AccountCode": "571901", + "Description": "A2CL RVU TRANSFER", + "GLRollup": "OTHER REVENUE", + "OBDollarsFinancialReporting": "IS - Other Operating Rev - Other operating revenue", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Assets - Current", + "SphAccountRollupLineItem": "Accounts Receivable - Other", + "SphAccountRollupName": "Balance Sheet - Assets - Current - Accounts Receivable - Other", + "ConfidenceScore": 0.7593035979954855, + "DateMapped": "7/26/2022", + "AccountId": 443, + "AccountCode": "105320", + "Description": "A2CL SERVICES RECEIVABLE", + "GLRollup": "SKCURRENTASSETS", + "OBDollarsFinancialReporting": "Bal - CURRENT ASSETS - Other current assets", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Liabilities - Current", + "SphAccountRollupLineItem": "Accrued Expenses", + "SphAccountRollupName": "Balance Sheet - Liabilities - Current - Accrued Expenses", + "ConfidenceScore": 0.8831137893820182, + "DateMapped": "7/26/2022", + "AccountId": 1348, + "AccountCode": "247152", + "Description": "AACN PHYSICIAN PMTS PAYABLE", + "GLRollup": "SKCURRENTLIABILITIES", + "OBDollarsFinancialReporting": "Bal - CURRENT LIABILITIES - Other current liabilities", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Assets - Current", + "SphAccountRollupLineItem": "Cash and Cash Equivalents", + "SphAccountRollupName": "Balance Sheet - Assets - Current - Cash and Cash Equivalents", + "ConfidenceScore": 0.7556597792049811, + "DateMapped": "7/26/2022", + "AccountId": 67, + "AccountCode": "100507", + "Description": "AAH CONTRLLD DISB ACCT M AND I", + "GLRollup": "SKCURRENTASSETS", + "OBDollarsFinancialReporting": "Bal - CURRENT ASSETS - Operating cash", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Assets - Current", + "SphAccountRollupLineItem": "Cash and Cash Equivalents", + "SphAccountRollupName": "Balance Sheet - Assets - Current - Cash and Cash Equivalents", + "ConfidenceScore": 0.4848799811631093, + "DateMapped": "7/26/2022", + "AccountId": 68, + "AccountCode": "100508", + "Description": "AAH CONTROLLED DISB ACCT TPA", + "GLRollup": "SKCURRENTASSETS", + "OBDollarsFinancialReporting": "Bal - CURRENT ASSETS - Operating cash", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Liabilities - Current", + "SphAccountRollupLineItem": "Accrued Expenses", + "SphAccountRollupName": "Balance Sheet - Liabilities - Current - Accrued Expenses", + "ConfidenceScore": 0.5334519575935484, + "DateMapped": "7/26/2022", + "AccountId": 1349, + "AccountCode": "247153", + "Description": "AAH PHYS PMT LIABILITY", + "GLRollup": "SKCURRENTLIABILITIES", + "OBDollarsFinancialReporting": "Bal - CURRENT LIABILITIES - Other current liabilities", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Balance Sheet", + "SphAccountRollupCategory": "Liabilities - Current", + "SphAccountRollupLineItem": "Accrued Expenses", + "SphAccountRollupName": "Balance Sheet - Liabilities - Current - Accrued Expenses", + "ConfidenceScore": 0.6824925674536929, + "DateMapped": "7/26/2022", + "AccountId": 1357, + "AccountCode": "247170", + "Description": "AAH PT INS REFUND REFUND PAYABLE", + "GLRollup": "SKCURRENTLIABILITIES", + "OBDollarsFinancialReporting": "Bal - CURRENT LIABILITIES - Other current liabilities", + "DSSAccountRollup1Name": "Exclude" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Income Statement", + "SphAccountRollupCategory": "Expenses - Operating", + "SphAccountRollupLineItem": "Facilities - Rent", + "SphAccountRollupName": "Income Statement - Expenses - Operating - Facilities - Rent", + "ConfidenceScore": 0.5506624314109377, + "DateMapped": "7/26/2022", + "AccountId": 895, + "AccountCode": "760005", + "Description": "ABBOTT HEMATOLOGY LEASE", + "GLRollup": "BUILDING AND EQUIPMENT RENTAL", + "OBDollarsFinancialReporting": "IS - EXPENSES: - Other expenses", + "DSSAccountRollup1Name": "Other Expense" + }, + { + "OrgPin": "0430", + "DatabaseName": "jazz tst GM Platform Demo.1 B onboarding 20200922.1", + "DatabaseFriendlyName": "StrataJazz Platform Cubekillers Demo 1 B Legacy onboarding", + "SphAccountRollupStatement": "Not Specified", + "SphAccountRollupCategory": "Not Specified", + "SphAccountRollupLineItem": "", + "SphAccountRollupName": "Not Specified - Not Specified - Not Specified", + "ConfidenceScore": 0.0, + "DateMapped": "", + "AccountId": 2563, + "AccountCode": "903155", + "Description": "ABHS VISITS", + "GLRollup": "STATISTICS", + "OBDollarsFinancialReporting": "Not Specified - Not Specified - Not Specified", + "DSSAccountRollup1Name": "Exclude" + } +] \ No newline at end of file diff --git a/tests/Strata.Excel.Core.Test.Unit/ExcelImportTests/TestExcelImport.cs b/tests/Strata.Excel.Core.Test.Unit/ExcelImportTests/TestExcelImport.cs new file mode 100644 index 0000000..9b40e5d --- /dev/null +++ b/tests/Strata.Excel.Core.Test.Unit/ExcelImportTests/TestExcelImport.cs @@ -0,0 +1,80 @@ +using ClosedXML.Excel; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Strata.Excel.Core.Import; +using Strata.Excel.TestUtilities; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using static Strata.Excel.Core.Test.Unit.ExcelExportTests.TestExcelExport; + +namespace Strata.Excel.Core.Test.Unit.ExcelImportTests +{ + [TestFixture] + public class TestExcelImport + { + [OneTimeSetUp] + public void RunBeforeAnyTests() + { + Environment.CurrentDirectory = TestContext.CurrentContext.TestDirectory; + // or identically under the hoods + Directory.SetCurrentDirectory(TestContext.CurrentContext.TestDirectory); + } + + private static readonly ResourceFileExtractor _extractor = new ResourceFileExtractor(".ExpectedResults."); + private static readonly ResourceFileExtractor _dataExtractor = new ResourceFileExtractor(".ExcelExportTests."); + + [Test] + + public void TestImportExcelWorksheet() + { + using (Stream stream = _extractor.ReadFileFromResourceToStream("TestCreateExcelWorkbook.xlsx")) + { + var wb = new XLWorkbook(stream); + wb.SaveAs(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "test.xlsx")); + var ws = wb.Worksheets.First(); + var result = ws.GetDataFromExcel(); + var data = _dataExtractor.ReadFileFromResource("data.json"); + var expected = JsonConvert.DeserializeObject>(data); + result.Should().HaveCount(expected.Count) + .And.Contain(row => expected.Select(d => d.AccountCode).Contains(row.AccountCode)) + .And.Contain(row => expected.Select(d => d.DatabaseName).Contains(row.DatabaseName)) + .And.Contain(row => expected.Select(d => d.DatabaseFriendlyName).Contains(row.DatabaseFriendlyName)) + .And.Contain(row => expected.Select(d => d.SphAccountRollupCategory).Contains(row.SphAccountRollupCategory)) + .And.Contain(row => expected.Select(d => d.Description).Contains(row.Description)); + } + } + + [Test] + + public void TestImportWhatsNeededExcelWorksheet() + { + using (Stream stream = _extractor.ReadFileFromResourceToStream("TestCreateExcelWorkbook.xlsx")) + { + var wb = new XLWorkbook(stream); + wb.SaveAs(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "test.xlsx")); + var ws = wb.Worksheets.First(); + var result = ws.GetDataFromExcel(); + var data = _dataExtractor.ReadFileFromResource("data.json"); + var expected = JsonConvert.DeserializeObject>(data); + result.Should().HaveCount(expected.Count) + .And.Contain(row => expected.Select(d => d.AccountCode).Contains(row.AccountCode)) + .And.Contain(row => expected.Select(d => d.DatabaseName).Contains(row.DatabaseName)) + .And.Contain(row => expected.Select(d => d.DatabaseFriendlyName).Contains(row.DatabaseFriendlyName)) + .And.Contain(row => expected.Select(d => d.SphAccountRollupCategory).Contains(row.SphAccountRollupCategory)) + .And.Contain(row => expected.Select(d => d.Description).Contains(row.Description)); + } + } + + internal class ImportWhatsNeeded + { + public string DatabaseName { get; set; } + public string DatabaseFriendlyName { get; set; } + public string SphAccountRollupCategory { get; set; } + public string AccountCode { get; set; } + public string Description { get; set; } + } + } +} diff --git a/tests/Strata.Excel.Core.Test.Unit/ExcelLoadTests/TestExcelLoad.cs b/tests/Strata.Excel.Core.Test.Unit/ExcelLoadTests/TestExcelLoad.cs new file mode 100644 index 0000000..bb8736e --- /dev/null +++ b/tests/Strata.Excel.Core.Test.Unit/ExcelLoadTests/TestExcelLoad.cs @@ -0,0 +1,76 @@ +using ClosedXML.Excel; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Strata.Excel.TestUtilities; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using static Strata.Excel.Core.ExportUtils; +using static Strata.Excel.Core.Test.Unit.ExcelExportTests.TestExcelExport; + +namespace Strata.Excel.Core.Test.Unit.ExcelLoadTests +{ + [TestFixture] + public class TestExcelLoad + { + [OneTimeSetUp] + public void RunBeforeAnyTests() + { + Environment.CurrentDirectory = TestContext.CurrentContext.TestDirectory; + // or identically under the hoods + Directory.SetCurrentDirectory(TestContext.CurrentContext.TestDirectory); + } + + private static readonly ResourceFileExtractor _dataExtractor = new ResourceFileExtractor(".ExcelExportTests."); + + [TestCaseSource(nameof(LoadTestCases))] + [Ignore("need to look for alternative that can handle large data")] + public void ExcelLoadTest(int load, int batchSize) + { + var jsonData = _dataExtractor.ReadFileFromResource("data.json"); + var loadData = JsonConvert.DeserializeObject>(jsonData); + var data = new List(); + while (data.Count < load) + { + data.AddRange(loadData); + } + Action action = () => + { + var expectedLoad = data.Count + 1; // Add 1 to include the header row + var options = new ExportOptions + { + EmptyMessage = "No Mappings", + BatchSize = batchSize + }; + options.AddColumnOptions("Confidence Score", NumberFormatId.ZeroPercent, XLAlignmentHorizontalValues.Right); + options.AddColumnOptions("Date Mapped", NumberFormatId.ShortDateSlash, XLAlignmentHorizontalValues.Right); + + var wb = CreateExcelWorkbook(data, options); +#pragma warning disable S125 // Sections of code should not be commented out + //var expected = $@"Excel Load Test {TestContext.CurrentContext.Test.Name}.xlsx"; + //wb.SaveAs(Path.Combine(@"C:\Git\excel.core\tests\Strata.Excel.Core.Test.Unit\ExcelLoadTests\", expected)); +#pragma warning restore S125 // Sections of code should not be commented + + // assert + wb.Worksheets.Should().HaveCount(1); + var ws = wb.Worksheet(1); + ws.Should().NotBeNull(); + var table = ws.Tables.First(); + table.RowCount().Should().Be(expectedLoad); + }; + action.Should().NotThrow() + .And.Subject.ExecutionTime().Should().BeLessThan(TimeSpan.FromMinutes(load / 3000)); + } + + public static IEnumerable LoadTestCases() + { + var batchSize = 100000; + for (var i = 48; i < 50; i += 2) + { + yield return new TestCaseData(i * 10000, batchSize).SetName($"{i}0000 rows with batches of {batchSize}"); + } + } + } +} diff --git a/tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbook.xlsx b/tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbook.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c57783fdb5c33922de99005df5ae2515fe448326 GIT binary patch literal 10097 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 Lhh6@R*D(JFAN#fi literal 0 HcmV?d00001 diff --git a/tests/Strata.Excel.Core.Test.Unit/Strata.Excel.Core.Test.Unit.csproj b/tests/Strata.Excel.Core.Test.Unit/Strata.Excel.Core.Test.Unit.csproj new file mode 100644 index 0000000..3037bc1 --- /dev/null +++ b/tests/Strata.Excel.Core.Test.Unit/Strata.Excel.Core.Test.Unit.csproj @@ -0,0 +1,43 @@ + + + + net5.0 + + + + + + + + + + + + Never + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + +