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 0000000..c57783f Binary files /dev/null and b/tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbook.xlsx differ diff --git a/tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbookWithTitlePage.xlsx b/tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbookWithTitlePage.xlsx new file mode 100644 index 0000000..18d4239 Binary files /dev/null and b/tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbookWithTitlePage.xlsx differ diff --git a/tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbookWithoutHumanizedHeadings.xlsx b/tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbookWithoutHumanizedHeadings.xlsx new file mode 100644 index 0000000..521508c Binary files /dev/null and b/tests/Strata.Excel.Core.Test.Unit/ExpectedResults/TestCreateExcelWorkbookWithoutHumanizedHeadings.xlsx differ 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 + + + + + + + + +