23 Commits

Author SHA1 Message Date
5f03056534 feat: Adds createMany for rooms, in prep for parsing / uploading a csv file of room loads.
All checks were successful
CI / Linux Tests (push) Successful in 7m0s
2026-02-04 21:06:05 -05:00
10dd0dac82 feat: Updates todos.
All checks were successful
CI / Linux Tests (push) Successful in 6m38s
2026-02-04 16:59:27 -05:00
3cc7fe9926 feat: Updates environment variables and datbase to allow postgres configuration for production environments.
All checks were successful
CI / Linux Tests (push) Successful in 6m39s
2026-02-03 09:41:31 -05:00
bad4a49f41 feat: Adds devcontainer
All checks were successful
CI / Linux Tests (push) Successful in 6m36s
2026-02-01 22:01:23 -05:00
9276f88426 feat: Updates to use swift-validations for database.
All checks were successful
CI / Linux Tests (push) Successful in 6m28s
2026-02-01 00:55:44 -05:00
a3fb87f86e feat: Removes api routes and controller as they're currently not used.
All checks were successful
CI / Linux Tests (push) Successful in 5m30s
2026-01-30 17:10:14 -05:00
b359a3317f feat: Adds trunk size database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m33s
2026-01-30 16:52:12 -05:00
e0ec15b91e feat: Adds rooms database tests. 2026-01-30 15:45:54 -05:00
44a0964181 feat: Adds equivalent length database tests. 2026-01-30 15:28:25 -05:00
0b78950d14 feat: Adds equipment info database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m26s
2026-01-30 15:16:09 -05:00
754019eac4 feat: Adds component loss database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m26s
2026-01-30 14:15:50 -05:00
a51e1b34d0 feat: Adds project database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m24s
2026-01-30 14:02:58 -05:00
c32ffcff8c feat: Begins live database client tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m35s
2026-01-30 12:02:11 -05:00
4f3cc2c7ea WIP: Cleans up ManualDClient and adds some more document strings.
Some checks failed
CI / Linux Tests (push) Failing after 7m3s
2026-01-29 22:56:39 -05:00
9b618d55fa WIP: Adds more tagged types for rectangular size calculations. 2026-01-29 20:50:33 -05:00
9379774fae feat: Begin using Tagged types
All checks were successful
CI / Linux Tests (push) Successful in 5m23s
2026-01-29 17:10:35 -05:00
18a5ef06d3 feat: Rename items in database client for consistency.
All checks were successful
CI / Linux Tests (push) Successful in 5m24s
2026-01-29 15:47:24 -05:00
6723f7a410 feat: Adds some documentation strings in ManualDCore module. 2026-01-29 15:16:26 -05:00
5440024038 feat: Renames EffectiveLength to EquivalentLength
All checks were successful
CI / Linux Tests (push) Successful in 9m34s
2026-01-29 11:25:20 -05:00
f005b43936 feat: Try to build image in ci instead of relying on just.
All checks were successful
CI / Linux Tests (push) Successful in 5m46s
2026-01-29 10:52:07 -05:00
f44b35ab3d feat: Try extractions/setup-just
Some checks failed
CI / Linux Tests (push) Failing after 7s
2026-01-29 10:46:00 -05:00
bbf9a8b390 feat: Setup just directly in ci workflow
Some checks failed
CI / Linux Tests (push) Failing after 10s
2026-01-29 10:43:49 -05:00
c52cee212f feat: Adds ci workflow.
Some checks failed
CI / Linux Tests (push) Failing after 5s
2026-01-29 10:36:29 -05:00
96 changed files with 5684 additions and 2386 deletions

View File

@@ -0,0 +1,40 @@
{
"name": "swift-manual-d-dev",
"image": "git.housh.dev/michael/swift-dev-container:latest",
"features": {
"ghcr.io/devcontainers/features/git:1": {
"version": "os-provided",
"ppa": "false"
},
"ghcr.io/jsburckhardt/devcontainer-features/just:1": {},
"ghcr.io/rocker-org/devcontainer-features/pandoc:1": {},
//"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/wxw-matt/devcontainer-features/apt:latest": {
"packages": "weasyprint gnupg2 tmux"
},
"ghcr.io/swift-server-community/swift-devcontainer-features/jemalloc:1": { },
},
"runArgs": [
"--cap-add=SYS_PTRACE",
"--security-opt",
"seccomp=unconfined"
],
"remoteEnv": {
// Set TERM, prevents problems when "xterm-ghostty" get's passed from host,
// which causes weird typing and display problems.
"TERM": "xterm-256color",
// Less backtrace error warnings from swift.
"SWIFT_BACKTRACE": "enable=no"
},
"remoteUser": "swift",
"forwardPorts": [8080],
"mounts": [
{
"type": "bind",
"source": "${localEnv:HOME}/.local/share/nvim",
"target": "/home/swift/.local/share/nvim"
}
]
}

31
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,31 @@
name: CI
on:
push:
branches:
- main
- dev
pull_request:
workflow_dispatch:
jobs:
ubuntu:
name: Linux Tests
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup buildx
uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.test
push: false
load: true
tags: michael/ductcalc:test
- name: Run Tests
run: |
docker run --rm michael/ductcalc:test swift test

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@ tailwindcss
*.pdf *.pdf
.env .env
.env* .env*
default.profraw

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "c3efcfd33bc1490f59ae406e4e5292027b2d01cafee9fc625652213505df50fb", "originHash" : "b6e6af1076a5bcce49e1231c44be25d770eaef278e2d1ce1c961446d49cb2d00",
"pins" : [ "pins" : [
{ {
"identity" : "async-http-client", "identity" : "async-http-client",
@@ -73,6 +73,15 @@
"version" : "1.53.0" "version" : "1.53.0"
} }
}, },
{
"identity" : "fluent-postgres-driver",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/fluent-postgres-driver.git",
"state" : {
"revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69",
"version" : "2.12.0"
}
},
{ {
"identity" : "fluent-sqlite-driver", "identity" : "fluent-sqlite-driver",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -100,6 +109,24 @@
"version" : "0.14.0" "version" : "0.14.0"
} }
}, },
{
"identity" : "postgres-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-kit.git",
"state" : {
"revision" : "7c079553e9cda74811e627775bf22e40a9405ad9",
"version" : "2.15.1"
}
},
{
"identity" : "postgres-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-nio.git",
"state" : {
"revision" : "d578b86fb2c8321b114d97cd70831d1a3e9531a6",
"version" : "1.30.1"
}
},
{ {
"identity" : "routing-kit", "identity" : "routing-kit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -397,6 +424,15 @@
"version" : "1.6.3" "version" : "1.6.3"
} }
}, },
{
"identity" : "swift-tagged",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-tagged",
"state" : {
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
"version" : "0.10.0"
}
},
{ {
"identity" : "swift-url-routing", "identity" : "swift-url-routing",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -406,6 +442,15 @@
"version" : "0.6.2" "version" : "0.6.2"
} }
}, },
{
"identity" : "swift-validations",
"kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-validations.git",
"state" : {
"revision" : "95ea5d267e37f6cdb9f91c5c8a01e718b9299db6",
"version" : "0.3.4"
}
},
{ {
"identity" : "vapor", "identity" : "vapor",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -6,10 +6,9 @@ let package = Package(
name: "swift-manual-d", name: "swift-manual-d",
products: [ products: [
.executable(name: "App", targets: ["App"]), .executable(name: "App", targets: ["App"]),
.library(name: "ApiController", targets: ["ApiController"]),
.library(name: "AuthClient", targets: ["AuthClient"]), .library(name: "AuthClient", targets: ["AuthClient"]),
.library(name: "DatabaseClient", targets: ["DatabaseClient"]), .library(name: "DatabaseClient", targets: ["DatabaseClient"]),
.library(name: "EnvClient", targets: ["EnvClient"]), .library(name: "EnvVars", targets: ["EnvVars"]),
.library(name: "FileClient", targets: ["FileClient"]), .library(name: "FileClient", targets: ["FileClient"]),
.library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]), .library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]),
.library(name: "PdfClient", targets: ["PdfClient"]), .library(name: "PdfClient", targets: ["PdfClient"]),
@@ -23,27 +22,30 @@ let package = Package(
.package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"), .package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"), .package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"), .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"),
.package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.6.0"),
.package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"), .package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"),
.package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"), .package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"),
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"), .package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"),
.package(url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0"), .package(url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0"),
.package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.0"), .package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.0"),
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"), .package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"),
.package(url: "https://github.com/m-housh/swift-validations.git", from: "0.1.0"),
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "App", name: "App",
dependencies: [ dependencies: [
.target(name: "ApiController"),
.target(name: "AuthClient"), .target(name: "AuthClient"),
.target(name: "DatabaseClient"), .target(name: "DatabaseClient"),
.target(name: "ViewController"), .target(name: "ViewController"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"), .product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
.product(name: "Vapor", package: "vapor"), .product(name: "Vapor", package: "vapor"),
.product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"),
@@ -51,16 +53,6 @@ let package = Package(
.product(name: "VaporRouting", package: "vapor-routing"), .product(name: "VaporRouting", package: "vapor-routing"),
] ]
), ),
.target(
name: "ApiController",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Vapor", package: "vapor"),
]
),
.target( .target(
name: "AuthClient", name: "AuthClient",
dependencies: [ dependencies: [
@@ -78,11 +70,20 @@ let package = Package(
.product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"), .product(name: "Fluent", package: "fluent"),
.product(name: "Vapor", package: "vapor"), .product(name: "Vapor", package: "vapor"),
.product(name: "Validations", package: "swift-validations"),
]
),
.testTarget(
name: "DatabaseClientTests",
dependencies: [
.target(name: "App"),
.target(name: "DatabaseClient"),
.product(name: "DependenciesTestSupport", package: "swift-dependencies"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
] ]
), ),
.target( .target(
name: "EnvClient", name: "EnvVars",
dependencies: [ dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"),
@@ -106,7 +107,7 @@ let package = Package(
.target( .target(
name: "PdfClient", name: "PdfClient",
dependencies: [ dependencies: [
.target(name: "EnvClient"), .target(name: "EnvVars"),
.target(name: "FileClient"), .target(name: "FileClient"),
.target(name: "ManualDCore"), .target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
@@ -120,11 +121,10 @@ let package = Package(
.target(name: "HTMLSnapshotTesting"), .target(name: "HTMLSnapshotTesting"),
.target(name: "PdfClient"), .target(name: "PdfClient"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
],
resources: [
.copy("__Snapshots__")
] ]
// ,
// resources: [
// .copy("__Snapshots__")
// ]
), ),
.target( .target(
name: "ProjectClient", name: "ProjectClient",
@@ -138,24 +138,19 @@ let package = Package(
.target( .target(
name: "ManualDCore", name: "ManualDCore",
dependencies: [ dependencies: [
.product(name: "CasePaths", package: "swift-case-paths"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"), .product(name: "Fluent", package: "fluent"),
.product(name: "URLRouting", package: "swift-url-routing"), .product(name: "URLRouting", package: "swift-url-routing"),
.product(name: "CasePaths", package: "swift-case-paths"),
]
),
.testTarget(
name: "ApiRouteTests",
dependencies: [
.target(name: "ManualDCore")
] ]
), ),
.target( .target(
name: "ManualDClient", name: "ManualDClient",
dependencies: [ dependencies: [
"ManualDCore", .target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Tagged", package: "swift-tagged"),
] ]
), ),
.target( .target(

View File

@@ -7,12 +7,7 @@
'Noto Color Emoji'; 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace; monospace;
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-600: oklch(57.7% 0.245 27.325);
--color-green-400: oklch(79.2% 0.209 151.711); --color-green-400: oklch(79.2% 0.209 151.711);
--color-indigo-600: oklch(51.1% 0.262 276.966);
--color-slate-300: oklch(86.9% 0.022 252.894);
--color-slate-900: oklch(20.8% 0.042 265.755);
--color-gray-200: oklch(92.8% 0.006 264.531); --color-gray-200: oklch(92.8% 0.006 264.531);
--color-gray-400: oklch(70.7% 0.022 261.325); --color-gray-400: oklch(70.7% 0.022 261.325);
--color-black: #000; --color-black: #000;
@@ -30,7 +25,6 @@
--text-3xl--line-height: calc(2.25 / 1.875); --text-3xl--line-height: calc(2.25 / 1.875);
--font-weight-bold: 700; --font-weight-bold: 700;
--radius-sm: 0.25rem; --radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem; --radius-lg: 0.5rem;
--ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
@@ -6218,6 +6212,9 @@
.hidden { .hidden {
display: none; display: none;
} }
.inline {
display: inline;
}
.inline-block { .inline-block {
display: inline-block; display: inline-block;
} }
@@ -6982,9 +6979,6 @@
.rounded-lg { .rounded-lg {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-selector { .rounded-selector {
border-radius: var(--radius-selector); border-radius: var(--radius-selector);
} }
@@ -7449,15 +7443,9 @@
.bg-error { .bg-error {
background-color: var(--color-error); background-color: var(--color-error);
} }
.bg-red-500 {
background-color: var(--color-red-500);
}
.bg-secondary { .bg-secondary {
background-color: var(--color-secondary); background-color: var(--color-secondary);
} }
.bg-white {
background-color: var(--color-white);
}
.divider-accent { .divider-accent {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
&:before, &:after { &:before, &:after {
@@ -7866,15 +7854,9 @@
} }
} }
} }
.px-3 {
padding-inline: calc(var(--spacing) * 3);
}
.px-4 { .px-4 {
padding-inline: calc(var(--spacing) * 4); padding-inline: calc(var(--spacing) * 4);
} }
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 { .py-2 {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
} }
@@ -8530,15 +8512,9 @@
.text-info { .text-info {
color: var(--color-info); color: var(--color-info);
} }
.text-slate-900 {
color: var(--color-slate-900);
}
.text-success { .text-success {
color: var(--color-success); color: var(--color-success);
} }
.text-white {
color: var(--color-white);
}
.lowercase { .lowercase {
text-transform: lowercase; text-transform: lowercase;
} }
@@ -8607,10 +8583,6 @@
--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25)); --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
} }
.shadow-lg {
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-sm { .shadow-sm {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -8619,13 +8591,6 @@
outline-style: var(--tw-outline-style); outline-style: var(--tw-outline-style);
outline-width: 1px; outline-width: 1px;
} }
.outline-1 {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.-outline-offset-1 {
outline-offset: calc(1px * -1);
}
.btn-ghost { .btn-ghost {
@layer daisyui.l1 { @layer daisyui.l1 {
&:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) { &:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
@@ -8650,9 +8615,6 @@
} }
} }
} }
.outline-slate-300 {
outline-color: var(--color-slate-300);
}
.filter { .filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
} }
@@ -9406,16 +9368,6 @@
} }
} }
} }
.invalid\:border-red-500 {
&:invalid {
border-color: var(--color-red-500);
}
}
.out-of-range\:border-red-500 {
&:out-of-range {
border-color: var(--color-red-500);
}
}
.hover\:bg-neutral { .hover\:bg-neutral {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -9423,13 +9375,6 @@
} }
} }
} }
.hover\:bg-red-600 {
&:hover {
@media (hover: hover) {
background-color: var(--color-red-600);
}
}
}
.hover\:text-white { .hover\:text-white {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -9437,22 +9382,6 @@
} }
} }
} }
.focus\:outline {
&:focus {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
}
.focus\:-outline-offset-2 {
&:focus {
outline-offset: calc(2px * -1);
}
}
.focus\:outline-indigo-600 {
&:focus {
outline-color: var(--color-indigo-600);
}
}
.data-active\:bg-neutral { .data-active\:bg-neutral {
&[data-active] { &[data-active] {
background-color: var(--color-neutral); background-color: var(--color-neutral);

View File

@@ -1,38 +0,0 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
extension DependencyValues {
public var apiController: ApiController {
get { self[ApiController.self] }
set { self[ApiController.self] = newValue }
}
}
@DependencyClient
public struct ApiController: Sendable {
public var json: @Sendable (Request) async throws -> (any Encodable)?
}
extension ApiController {
public struct Request: Sendable {
public let route: SiteRoute.Api
public let logger: Logger
public init(route: SiteRoute.Api, logger: Logger) {
self.route = route
self.logger = logger
}
}
}
extension ApiController: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
json: { request in
try await request.route.respond(logger: request.logger)
}
)
}

View File

@@ -1,129 +0,0 @@
import DatabaseClient
import Dependencies
import Logging
import ManualDCore
extension SiteRoute.Api {
func respond(logger: Logger) async throws -> (any Encodable)? {
switch self {
case .project(let route):
return try await route.respond(logger: logger)
case .room(let route):
return try await route.respond(logger: logger)
case .equipment(let route):
return try await route.respond(logger: logger)
case .componentLoss(let route):
return try await route.respond(logger: logger)
}
}
}
extension SiteRoute.Api.ProjectRoute {
func respond(logger: Logger) async throws -> (any Encodable)? {
@Dependency(\.database) var database
switch self {
case .create(let request):
// return try await database.projects.create(request)
// FIX:
fatalError()
case .delete(let id):
try await database.projects.delete(id)
return nil
case .detail(let id, let route):
switch route {
case .index:
return try await database.projects.detail(id)
case .completedSteps:
// FIX:
fatalError()
}
case .get(let id):
guard let project = try await database.projects.get(id) else {
logger.error("Project not found for id: \(id)")
throw ApiError("Project not found.")
}
return project
case .index:
// FIX: Fix to return projects.
return [Project]()
}
}
}
extension SiteRoute.Api.RoomRoute {
func respond(logger: Logger) async throws -> (any Encodable)? {
@Dependency(\.database) var database
switch self {
case .create(let request):
return try await database.rooms.create(request)
case .delete(let id):
try await database.rooms.delete(id)
return nil
case .get(let id):
guard let room = try await database.rooms.get(id) else {
logger.error("Room not found for id: \(id)")
throw ApiError("Room not found.")
}
return room
}
}
}
extension SiteRoute.Api.EquipmentRoute {
func respond(logger: Logger) async throws -> (any Encodable)? {
@Dependency(\.database) var database
switch self {
case .create(let request):
return try await database.equipment.create(request)
case .delete(let id):
try await database.equipment.delete(id)
return nil
case .fetch(let projectID):
return try await database.equipment.fetch(projectID)
case .get(let id):
guard let room = try await database.equipment.get(id) else {
logger.error("Equipment not found for id: \(id)")
throw ApiError("Equipment not found.")
}
return room
}
}
}
extension SiteRoute.Api.ComponentLossRoute {
func respond(logger: Logger) async throws -> (any Encodable)? {
@Dependency(\.database) var database
switch self {
case .create(let request):
return try await database.componentLoss.create(request)
case .delete(let id):
try await database.componentLoss.delete(id)
return nil
case .fetch(let projectID):
return try await database.componentLoss.fetch(projectID)
case .get(let id):
guard let room = try await database.componentLoss.get(id) else {
logger.error("Component loss not found for id: \(id)")
throw ApiError("Component loss not found.")
}
return room
}
}
}
public struct ApiError: Error {
let message: String
init(_ message: String) {
self.message = message
}
}

View File

@@ -1,37 +1,37 @@
import ApiController // import ApiController
import ManualDCore // import ManualDCore
import Vapor // import Vapor
//
extension ApiController { // extension ApiController {
//
func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws // func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws
-> any AsyncResponseEncodable // -> any AsyncResponseEncodable
{ // {
guard let encodable = try await json(.init(route: route, logger: request.logger)) else { // guard let encodable = try await json(.init(route: route, logger: request.logger)) else {
return HTTPStatus.ok // return HTTPStatus.ok
} // }
return AnyJSONResponse(value: encodable) // return AnyJSONResponse(value: encodable)
} // }
} // }
//
struct AnyJSONResponse: AsyncResponseEncodable { // struct AnyJSONResponse: AsyncResponseEncodable {
public var headers: HTTPHeaders = ["Content-Type": "application/json"] // public var headers: HTTPHeaders = ["Content-Type": "application/json"]
let value: any Encodable // let value: any Encodable
//
init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) { // init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) {
if additionalHeaders.contains(name: .contentType) { // if additionalHeaders.contains(name: .contentType) {
self.headers = additionalHeaders // self.headers = additionalHeaders
} else { // } else {
headers.add(contentsOf: additionalHeaders) // headers.add(contentsOf: additionalHeaders)
} // }
self.value = value // self.value = value
} // }
//
func encodeResponse(for request: Request) async throws -> Response { // func encodeResponse(for request: Request) async throws -> Response {
try Response( // try Response(
status: .ok, // status: .ok,
headers: headers, // headers: headers,
body: .init(data: JSONEncoder().encode(value)) // body: .init(data: JSONEncoder().encode(value))
) // )
} // }
} // }

View File

@@ -1,7 +1,8 @@
import ApiController // import ApiController
import AuthClient import AuthClient
import DatabaseClient import DatabaseClient
import Dependencies import Dependencies
import EnvVars
import ManualDCore import ManualDCore
import PdfClient import PdfClient
import Vapor import Vapor
@@ -12,27 +13,31 @@ import ViewController
struct DependenciesMiddleware: AsyncMiddleware { struct DependenciesMiddleware: AsyncMiddleware {
private let values: DependencyValues.Continuation private let values: DependencyValues.Continuation
private let apiController: ApiController // private let apiController: ApiController
private let database: DatabaseClient private let database: DatabaseClient
private let environment: EnvVars
private let viewController: ViewController private let viewController: ViewController
init( init(
database: DatabaseClient, database: DatabaseClient,
apiController: ApiController = .liveValue, environment: EnvVars,
// apiController: ApiController = .liveValue,
viewController: ViewController = .liveValue viewController: ViewController = .liveValue
) { ) {
self.values = withEscapedDependencies { $0 } self.values = withEscapedDependencies { $0 }
self.apiController = apiController // self.apiController = apiController
self.database = database self.database = database
self.environment = environment
self.viewController = viewController self.viewController = viewController
} }
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
try await values.yield { try await values.yield {
try await withDependencies { try await withDependencies {
$0.apiController = apiController // $0.apiController = apiController
$0.authClient = .live(on: request) $0.auth = .live(on: request)
$0.database = database $0.database = database
$0.environment = environment
// $0.dateFormatter = .liveValue // $0.dateFormatter = .liveValue
$0.viewController = viewController $0.viewController = viewController
$0.pdfClient = .liveValue $0.pdfClient = .liveValue

View File

@@ -1,7 +1,9 @@
import DatabaseClient import DatabaseClient
import Dependencies import Dependencies
import Elementary import Elementary
import EnvVars
import Fluent import Fluent
import FluentPostgresDriver
import FluentSQLiteDriver import FluentSQLiteDriver
import ManualDCore import ManualDCore
import NIOSSL import NIOSSL
@@ -14,12 +16,15 @@ import ViewController
// configures your application // configures your application
public func configure( public func configure(
_ app: Application, _ app: Application,
in environment: EnvVars,
makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) } makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) }
) async throws { ) async throws {
// Setup the database client. // Setup the database client.
let databaseClient = try await setupDatabase(on: app, factory: makeDatabaseClient) let databaseClient = try await setupDatabase(
on: app, environment: environment, factory: makeDatabaseClient
)
// Add the global middlewares. // Add the global middlewares.
addMiddleware(to: app, database: databaseClient) addMiddleware(to: app, database: databaseClient, environment: environment)
#if DEBUG #if DEBUG
// Live reload of the application for development when launched with the `./swift-dev` command // Live reload of the application for development when launched with the `./swift-dev` command
// app.lifecycle.use(BrowserSyncHandler()) // app.lifecycle.use(BrowserSyncHandler())
@@ -33,7 +38,11 @@ public func configure(
addCommands(to: app) addCommands(to: app)
} }
private func addMiddleware(to app: Application, database databaseClient: DatabaseClient) { private func addMiddleware(
to app: Application,
database databaseClient: DatabaseClient,
environment: EnvVars
) {
// cors middleware should come before default error middleware using `at: .beginning` // cors middleware should come before default error middleware using `at: .beginning`
let corsConfiguration = CORSMiddleware.Configuration( let corsConfiguration = CORSMiddleware.Configuration(
allowedOrigin: .all, allowedOrigin: .all,
@@ -48,16 +57,20 @@ private func addMiddleware(to app: Application, database databaseClient: Databas
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
app.middleware.use(app.sessions.middleware) app.middleware.use(app.sessions.middleware)
app.middleware.use(DependenciesMiddleware(database: databaseClient)) app.middleware.use(DependenciesMiddleware(database: databaseClient, environment: environment))
} }
private func setupDatabase( private func setupDatabase(
on app: Application, on app: Application,
environment: EnvVars,
factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient
) async throws -> DatabaseClient { ) async throws -> DatabaseClient {
switch app.environment { switch app.environment {
case .production, .development: case .production:
let dbFileName = Environment.get("SQLITE_FILENAME") ?? "db.sqlite" let configuration = try environment.postgresConfiguration()
app.databases.use(.postgres(configuration: configuration), as: .psql)
case .development:
let dbFileName = environment.sqlitePath ?? "db.sqlite"
app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite) app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite)
default: default:
app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite) app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite)
@@ -65,9 +78,7 @@ private func setupDatabase(
let databaseClient = makeDatabaseClient(app.db) let databaseClient = makeDatabaseClient(app.db)
if app.environment != .testing {
try await app.migrations.add(databaseClient.migrations()) try await app.migrations.add(databaseClient.migrations())
}
return databaseClient return databaseClient
} }
@@ -102,8 +113,6 @@ extension SiteRoute {
fileprivate func middleware() -> [any Middleware]? { fileprivate func middleware() -> [any Middleware]? {
switch self { switch self {
case .api:
return nil
case .health: case .health:
return nil return nil
case .view(let route): case .view(let route):
@@ -119,13 +128,10 @@ private func siteHandler(
request: Request, request: Request,
route: SiteRoute route: SiteRoute
) async throws -> any AsyncResponseEncodable { ) async throws -> any AsyncResponseEncodable {
@Dependency(\.apiController) var apiController
@Dependency(\.viewController) var viewController @Dependency(\.viewController) var viewController
@Dependency(\.projectClient) var projectClient @Dependency(\.projectClient) var projectClient
switch route { switch route {
case .api(let route):
return try await apiController.respond(route, request: request)
case .health: case .health:
return HTTPStatus.ok return HTTPStatus.ok
// Generating a pdf return's a `Response` instead of `HTML` like other views, so we // Generating a pdf return's a `Response` instead of `HTML` like other views, so we
@@ -136,3 +142,30 @@ private func siteHandler(
return try await viewController.respond(route: route, request: request) return try await viewController.respond(route: route, request: request)
} }
} }
extension EnvVars {
func postgresConfiguration() throws -> SQLPostgresConfiguration {
guard let hostname = postgresHostname,
let username = postgresUsername,
let password = postgresPassword,
let database = postgresDatabase
else {
throw EnvError("Missing environment variables for postgres connection.")
}
return .init(
hostname: hostname,
username: username,
password: password,
database: database,
tls: .disable
)
}
}
struct EnvError: Error {
let reason: String
init(_ reason: String) {
self.reason = reason
}
}

View File

@@ -1,5 +1,6 @@
import DatabaseClient import DatabaseClient
import Dependencies import Dependencies
import EnvVars
import Logging import Logging
import NIOCore import NIOCore
import NIOPosix import NIOPosix
@@ -17,11 +18,15 @@ enum Entrypoint {
// You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency. // You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency.
// Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down. // Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down.
// If enabled, you should be careful about calling async functions before this point as it can cause assertion failures. // If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
// let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() let executorTakeoverSuccess =
// app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)]) NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
app.logger.debug(
"Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor",
metadata: ["success": .stringConvertible(executorTakeoverSuccess)]
)
do { do {
try await configure(app) try await configure(app, in: EnvVars.live())
} catch { } catch {
app.logger.report(error: error) app.logger.report(error: error)
try? await app.asyncShutdown() try? await app.asyncShutdown()

View File

@@ -5,17 +5,23 @@ import ManualDCore
import Vapor import Vapor
extension DependencyValues { extension DependencyValues {
public var authClient: AuthClient { /// Authentication dependency, for handling authentication tasks.
public var auth: AuthClient {
get { self[AuthClient.self] } get { self[AuthClient.self] }
set { self[AuthClient.self] = newValue } set { self[AuthClient.self] = newValue }
} }
} }
/// Represents authentication tasks that are used in the application.
@DependencyClient @DependencyClient
public struct AuthClient: Sendable { public struct AuthClient: Sendable {
/// Create a new user and log them in.
public var createAndLogin: @Sendable (User.Create) async throws -> User public var createAndLogin: @Sendable (User.Create) async throws -> User
/// Get the current user.
public var currentUser: @Sendable () throws -> User public var currentUser: @Sendable () throws -> User
/// Login a user.
public var login: @Sendable (User.Login) async throws -> User public var login: @Sendable (User.Login) async throws -> User
/// Logout a user.
public var logout: @Sendable () throws -> Void public var logout: @Sendable () throws -> Void
} }

View File

@@ -4,23 +4,132 @@ import FluentKit
import ManualDCore import ManualDCore
extension DependencyValues { extension DependencyValues {
/// The database dependency.
public var database: DatabaseClient { public var database: DatabaseClient {
get { self[DatabaseClient.self] } get { self[DatabaseClient.self] }
set { self[DatabaseClient.self] = newValue } set { self[DatabaseClient.self] = newValue }
} }
} }
/// Represents the database interactions used by the application.
@DependencyClient @DependencyClient
public struct DatabaseClient: Sendable { public struct DatabaseClient: Sendable {
/// Database migrations.
public var migrations: Migrations public var migrations: Migrations
/// Interactions with the projects table.
public var projects: Projects public var projects: Projects
/// Interactions with the rooms table.
public var rooms: Rooms public var rooms: Rooms
/// Interactions with the equipment table.
public var equipment: Equipment public var equipment: Equipment
public var componentLoss: ComponentLoss /// Interactions with the component losses table.
public var effectiveLength: EffectiveLengthClient public var componentLosses: ComponentLosses
/// Interactions with the equivalent lengths table.
public var equivalentLengths: EquivalentLengths
/// Interactions with the users table.
public var users: Users public var users: Users
public var userProfile: UserProfile /// Interactions with the user profiles table.
public var userProfiles: UserProfiles
/// Interactions with the trunk sizes table.
public var trunkSizes: TrunkSizes public var trunkSizes: TrunkSizes
@DependencyClient
public struct ComponentLosses: Sendable {
public var create:
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
public var update:
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
ComponentPressureLoss
}
@DependencyClient
public struct EquivalentLengths: Sendable {
public var create: @Sendable (EquivalentLength.Create) async throws -> EquivalentLength
public var delete: @Sendable (EquivalentLength.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [EquivalentLength]
public var fetchMax: @Sendable (Project.ID) async throws -> EquivalentLength.MaxContainer
public var get: @Sendable (EquivalentLength.ID) async throws -> EquivalentLength?
public var update:
@Sendable (EquivalentLength.ID, EquivalentLength.Update) async throws -> EquivalentLength
}
@DependencyClient
public struct Equipment: Sendable {
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
public var update:
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
}
@DependencyClient
public struct Migrations: Sendable {
public var all: @Sendable () async throws -> [any AsyncMigration]
public func callAsFunction() async throws -> [any AsyncMigration] {
try await self.all()
}
}
@DependencyClient
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
}
@DependencyClient
public struct Rooms: Sendable {
public var create: @Sendable (Room.Create) async throws -> Room
public var createMany: @Sendable ([Room.Create]) async throws -> [Room]
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
}
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (TrunkSize.Create) async throws -> TrunkSize
public var delete: @Sendable (TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [TrunkSize]
public var get: @Sendable (TrunkSize.ID) async throws -> TrunkSize?
public var update:
@Sendable (TrunkSize.ID, TrunkSize.Update) async throws ->
TrunkSize
}
@DependencyClient
public struct UserProfiles: Sendable {
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
public var delete: @Sendable (User.Profile.ID) async throws -> Void
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
}
@DependencyClient
public struct Users: Sendable {
public var create: @Sendable (User.Create) async throws -> User
public var delete: @Sendable (User.ID) async throws -> Void
public var get: @Sendable (User.ID) async throws -> User?
public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void
// public var token: @Sendable (User.ID) async throws -> User.Token
}
} }
extension DatabaseClient: TestDependencyKey { extension DatabaseClient: TestDependencyKey {
@@ -29,10 +138,10 @@ extension DatabaseClient: TestDependencyKey {
projects: .testValue, projects: .testValue,
rooms: .testValue, rooms: .testValue,
equipment: .testValue, equipment: .testValue,
componentLoss: .testValue, componentLosses: .testValue,
effectiveLength: .testValue, equivalentLengths: .testValue,
users: .testValue, users: .testValue,
userProfile: .testValue, userProfiles: .testValue,
trunkSizes: .testValue trunkSizes: .testValue
) )
@@ -42,44 +151,11 @@ extension DatabaseClient: TestDependencyKey {
projects: .live(database: database), projects: .live(database: database),
rooms: .live(database: database), rooms: .live(database: database),
equipment: .live(database: database), equipment: .live(database: database),
componentLoss: .live(database: database), componentLosses: .live(database: database),
effectiveLength: .live(database: database), equivalentLengths: .live(database: database),
users: .live(database: database), users: .live(database: database),
userProfile: .live(database: database), userProfiles: .live(database: database),
trunkSizes: .live(database: database) trunkSizes: .live(database: database)
) )
} }
} }
extension DatabaseClient {
@DependencyClient
public struct Migrations: Sendable {
public var run: @Sendable () async throws -> [any AsyncMigration]
public func callAsFunction() async throws -> [any AsyncMigration] {
try await self.run()
}
}
}
extension DatabaseClient.Migrations: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.Migrations: DependencyKey {
public static let liveValue = Self(
run: {
[
Project.Migrate(),
User.Migrate(),
User.Token.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.Migrate(),
EffectiveLength.Migrate(),
TrunkSize.Migrate(),
]
}
)
}

View File

@@ -0,0 +1,48 @@
import Validations
extension Validator {
static func validate<Child: Validatable>(
_ toChild: KeyPath<Value, [Child]>
)
-> Self
{
self.mapValue({ $0[keyPath: toChild] }, with: ArrayValidator())
}
static func validate<Child: Validatable>(
_ toChild: KeyPath<Value, [Child]?>
)
-> Self
{
self.mapValue({ $0[keyPath: toChild] }, with: ArrayValidator().optional())
}
}
extension Array where Element: Validatable {
static func validator() -> some Validation<Self> {
ArrayValidator<Element>()
}
}
struct ArrayValidator<Element>: Validation where Element: Validatable {
func validate(_ value: [Element]) throws {
for item in value {
try item.validate()
}
}
}
struct ForEachValidator<T, E>: Validation where T: Validation, T.Value == E {
let validator: T
init(@ValidationBuilder<E> builder: () -> T) {
self.validator = builder()
}
func validate(_ value: [E]) throws {
for item in value {
try validator.validate(item)
}
}
}

View File

@@ -3,31 +3,19 @@ import DependenciesMacros
import Fluent import Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import SQLKit
import Validations
extension DatabaseClient { extension DatabaseClient.ComponentLosses: TestDependencyKey {
@DependencyClient
public struct ComponentLoss: Sendable {
public var create:
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
public var update:
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
ComponentPressureLoss
}
}
extension DatabaseClient.ComponentLoss: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
} }
extension DatabaseClient.ComponentLoss { extension DatabaseClient.ComponentLosses {
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
let model = try request.toModel() let model = request.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -48,13 +36,13 @@ extension DatabaseClient.ComponentLoss {
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() } try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
}, },
update: { id, updates in update: { id, updates in
try updates.validate() // try updates.validate()
guard let model = try await ComponentLossModel.find(id, on: database) else { guard let model = try await ComponentLossModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -64,40 +52,9 @@ extension DatabaseClient.ComponentLoss {
extension ComponentPressureLoss.Create { extension ComponentPressureLoss.Create {
func toModel() throws(ValidationError) -> ComponentLossModel { func toModel() -> ComponentLossModel {
try validate()
return .init(name: name, value: value, projectID: projectID) return .init(name: name, value: value, projectID: projectID)
} }
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Component loss name should not be empty.")
}
guard value > 0 else {
throw ValidationError("Component loss value should be greater than 0.")
}
guard value < 1.0 else {
throw ValidationError("Component loss value should be less than 1.0.")
}
}
}
extension ComponentPressureLoss.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Component loss name should not be empty.")
}
}
if let value {
guard value > 0 else {
throw ValidationError("Component loss value should be greater than 0.")
}
guard value < 1.0 else {
throw ValidationError("Component loss value should be less than 1.0.")
}
}
}
} }
extension ComponentPressureLoss { extension ComponentPressureLoss {
@@ -184,3 +141,19 @@ final class ComponentLossModel: Model, @unchecked Sendable {
} }
} }
} }
extension ComponentLossModel: Validatable {
var body: some Validation<ComponentLossModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(\.value) {
Double.greaterThan(0.0)
Double.lessThanOrEquals(1.0)
}
.errorLabel("Value", inline: true)
}
}
}

View File

@@ -3,29 +3,16 @@ import DependenciesMacros
import Fluent import Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
extension DatabaseClient {
@DependencyClient
public struct Equipment: Sendable {
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
public var update:
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
}
}
extension DatabaseClient.Equipment: TestDependencyKey { extension DatabaseClient.Equipment: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
}
extension DatabaseClient.Equipment {
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
let model = try request.toModel() let model = request.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -51,10 +38,9 @@ extension DatabaseClient.Equipment {
guard let model = try await EquipmentModel.find(id, on: database) else { guard let model = try await EquipmentModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate()
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -64,8 +50,7 @@ extension DatabaseClient.Equipment {
extension EquipmentInfo.Create { extension EquipmentInfo.Create {
func toModel() throws(ValidationError) -> EquipmentModel { func toModel() -> EquipmentModel {
try validate()
return .init( return .init(
staticPressure: staticPressure, staticPressure: staticPressure,
heatingCFM: heatingCFM, heatingCFM: heatingCFM,
@@ -74,44 +59,6 @@ extension EquipmentInfo.Create {
) )
} }
func validate() throws(ValidationError) {
guard staticPressure >= 0 else {
throw ValidationError("Equipment info static pressure should be greater than 0.")
}
guard staticPressure <= 1.0 else {
throw ValidationError("Equipment info static pressure should be less than 1.0.")
}
guard heatingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
guard coolingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
}
extension EquipmentInfo.Update {
var hasUpdates: Bool {
staticPressure != nil || heatingCFM != nil || coolingCFM != nil
}
func validate() throws(ValidationError) {
if let staticPressure {
guard staticPressure >= 0 else {
throw ValidationError("Equipment info static pressure should be greater than 0.")
}
}
if let heatingCFM {
guard heatingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
if let coolingCFM {
guard coolingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
}
} }
extension EquipmentInfo { extension EquipmentInfo {
@@ -211,3 +158,22 @@ final class EquipmentModel: Model, @unchecked Sendable {
} }
} }
} }
extension EquipmentModel: Validatable {
var body: some Validation<EquipmentModel> {
Validator.accumulating {
Validator.validate(\.staticPressure) {
Double.greaterThan(0.0)
Double.lessThan(1.0)
}
.errorLabel("Static Pressure", inline: true)
Validator.validate(\.heatingCFM, with: .greaterThan(0))
.errorLabel("Heating CFM", inline: true)
Validator.validate(\.coolingCFM, with: .greaterThan(0))
.errorLabel("Cooling CFM", inline: true)
}
}
}

View File

@@ -3,28 +3,16 @@ import DependenciesMacros
import Fluent import Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
extension DatabaseClient { extension DatabaseClient.EquivalentLengths: TestDependencyKey {
@DependencyClient
public struct EffectiveLengthClient: Sendable {
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength]
public var fetchMax: @Sendable (Project.ID) async throws -> EffectiveLength.MaxContainer
public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
public var update:
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
}
}
extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
let model = try request.toModel() let model = try request.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -66,7 +54,7 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
} }
try model.applyUpdates(updates) try model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -74,10 +62,12 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
} }
} }
extension EffectiveLength.Create { extension EquivalentLength.Create {
func toModel() throws -> EffectiveLengthModel { func toModel() throws -> EffectiveLengthModel {
try validate() if groups.count > 0 {
try [EquivalentLength.FittingGroup].validator().validate(groups)
}
return try .init( return try .init(
name: name, name: name,
type: type.rawValue, type: type.rawValue,
@@ -86,15 +76,9 @@ extension EffectiveLength.Create {
projectID: projectID projectID: projectID
) )
} }
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Effective length name can not be empty.")
}
}
} }
extension EffectiveLength { extension EquivalentLength {
struct Migrate: AsyncMigration { struct Migrate: AsyncMigration {
let name = "CreateEffectiveLength" let name = "CreateEffectiveLength"
@@ -173,20 +157,20 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
$project.id = projectID $project.id = projectID
} }
func toDTO() throws -> EffectiveLength { func toDTO() throws -> EquivalentLength {
try .init( try .init(
id: requireID(), id: requireID(),
projectID: $project.id, projectID: $project.id,
name: name, name: name,
type: .init(rawValue: type)!, type: .init(rawValue: type)!,
straightLengths: straightLengths, straightLengths: straightLengths,
groups: JSONDecoder().decode([EffectiveLength.Group].self, from: groups), groups: JSONDecoder().decode([EquivalentLength.FittingGroup].self, from: groups),
createdAt: createdAt!, createdAt: createdAt!,
updatedAt: updatedAt! updatedAt: updatedAt!
) )
} }
func applyUpdates(_ updates: EffectiveLength.Update) throws { func applyUpdates(_ updates: EquivalentLength.Update) throws {
if let name = updates.name, name != self.name { if let name = updates.name, name != self.name {
self.name = name self.name = name
} }
@@ -197,7 +181,51 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
self.straightLengths = straightLengths self.straightLengths = straightLengths
} }
if let groups = updates.groups { if let groups = updates.groups {
if groups.count > 0 {
try [EquivalentLength.FittingGroup].validator().validate(groups)
}
self.groups = try JSONEncoder().encode(groups) self.groups = try JSONEncoder().encode(groups)
} }
} }
} }
extension EffectiveLengthModel: Validatable {
var body: some Validation<EffectiveLengthModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(
\.straightLengths,
with: [Int].empty().or(
ForEachValidator {
Int.greaterThan(0)
})
)
.errorLabel("Straight Lengths", inline: true)
}
}
}
extension EquivalentLength.FittingGroup: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
Validator.validate(\.group) {
Int.greaterThanOrEquals(1)
Int.lessThanOrEquals(12)
}
.errorLabel("Group", inline: true)
Validator.validate(\.letter, with: .regex(matching: "[a-zA-Z]"))
.errorLabel("Letter", inline: true)
Validator.validate(\.value, with: .greaterThan(0))
.errorLabel("Value", inline: true)
Validator.validate(\.quantity, with: .greaterThanOrEquals(1))
.errorLabel("Quantity", inline: true)
}
}
}

View File

@@ -0,0 +1,22 @@
import Dependencies
import ManualDCore
extension DatabaseClient.Migrations: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
all: {
[
Project.Migrate(),
User.Migrate(),
User.Token.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.Migrate(),
EquivalentLength.Migrate(),
TrunkSize.Migrate(),
]
}
)
}

View File

@@ -0,0 +1,10 @@
import Fluent
import Validations
extension Model where Self: Validations.Validatable {
func validateAndSave(on database: any Database) async throws {
try self.validate()
try await self.save(on: database)
}
}

View File

@@ -3,20 +3,7 @@ import DependenciesMacros
import Fluent import Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
extension DatabaseClient {
@DependencyClient
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
}
}
extension DatabaseClient.Projects: TestDependencyKey { extension DatabaseClient.Projects: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
@@ -24,8 +11,8 @@ extension DatabaseClient.Projects: TestDependencyKey {
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { userID, request in create: { userID, request in
let model = try request.toModel(userID: userID) let model = request.toModel(userID: userID)
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -35,28 +22,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
try await model.delete(on: database) try await model.delete(on: database)
}, },
detail: { id in detail: { id in
guard let model = try await ProjectModel.fetchDetail(for: id, on: database)
let model = try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
// TODO: Different error ?? // TODO: Different error ??
guard let equipmentInfo = model.equipment else { return nil } guard let equipmentInfo = model.equipment else { return nil }
@@ -76,44 +42,25 @@ extension DatabaseClient.Projects: TestDependencyKey {
try await ProjectModel.find(id, on: database).map { try $0.toDTO() } try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
}, },
getCompletedSteps: { id in getCompletedSteps: { id in
let roomsCount = try await RoomModel.query(on: database) let model = try await ProjectModel.fetchDetail(for: id, on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.all()
var equivalentLengthsCompleted = false var equivalentLengthsCompleted = false
if equivalentLengths.filter({ $0.type == "supply" }).first != nil, if model.equivalentLengths.filter({ $0.type == "supply" }).first != nil,
equivalentLengths.filter({ $0.type == "return" }).first != nil model.equivalentLengths.filter({ $0.type == "return" }).first != nil
{ {
equivalentLengthsCompleted = true equivalentLengthsCompleted = true
} }
let componentLosses = try await ComponentLossModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equipmentInfo = try await EquipmentModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.first()
return .init( return .init(
equipmentInfo: equipmentInfo != nil, equipmentInfo: model.equipment != nil,
rooms: roomsCount > 0, rooms: model.rooms.count > 0,
equivalentLength: equivalentLengthsCompleted, equivalentLength: equivalentLengthsCompleted,
frictionRate: componentLosses > 0 frictionRate: model.componentLosses.count > 0
) )
}, },
getSensibleHeatRatio: { id in getSensibleHeatRatio: { id in
guard guard
let shr = try await ProjectModel.query(on: database) let model = try await ProjectModel.query(on: database)
.field(\.$id) .field(\.$id)
.field(\.$sensibleHeatRatio) .field(\.$sensibleHeatRatio)
.filter(\.$id == id) .filter(\.$id == id)
@@ -121,7 +68,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
else { else {
throw NotFoundError() throw NotFoundError()
} }
return shr.sensibleHeatRatio return model.sensibleHeatRatio
}, },
fetch: { userID, request in fetch: { userID, request in
try await ProjectModel.query(on: database) try await ProjectModel.query(on: database)
@@ -135,10 +82,9 @@ extension DatabaseClient.Projects: TestDependencyKey {
guard let model = try await ProjectModel.find(id, on: database) else { guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate()
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -148,8 +94,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
extension Project.Create { extension Project.Create {
func toModel(userID: User.ID) throws -> ProjectModel { func toModel(userID: User.ID) -> ProjectModel {
try validate()
return .init( return .init(
name: name, name: name,
streetAddress: streetAddress, streetAddress: streetAddress,
@@ -160,70 +105,6 @@ extension Project.Create {
) )
} }
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Project name should not be empty.")
}
guard !streetAddress.isEmpty else {
throw ValidationError("Project street address should not be empty.")
}
guard !city.isEmpty else {
throw ValidationError("Project city should not be empty.")
}
guard !state.isEmpty else {
throw ValidationError("Project state should not be empty.")
}
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
}
extension Project.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Project name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("Project street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("Project city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("Project state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
} }
extension Project { extension Project {
@@ -241,7 +122,7 @@ extension Project {
.field("sensibleHeatRatio", .double) .field("sensibleHeatRatio", .double)
.field("createdAt", .datetime) .field("createdAt", .datetime)
.field("updatedAt", .datetime) .field("updatedAt", .datetime)
.field("userID", .uuid, .required, .references(UserModel.schema, "id")) .field("userID", .uuid, .required, .references(UserModel.schema, "id", onDelete: .cascade))
.unique(on: "userID", "name") .unique(on: "userID", "name")
.create() .create()
} }
@@ -364,4 +245,67 @@ final class ProjectModel: Model, @unchecked Sendable {
self.sensibleHeatRatio = sensibleHeatRatio self.sensibleHeatRatio = sensibleHeatRatio
} }
} }
/// Returns a ``ProjectModel`` with all the relations eagerly loaded.
static func fetchDetail(
for projectID: Project.ID,
on database: any Database
) async throws -> ProjectModel {
guard
let model =
try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == projectID)
.first()
else {
throw NotFoundError()
}
return model
}
}
extension ProjectModel: Validatable {
var body: some Validation<ProjectModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(\.streetAddress, with: .notEmpty())
.errorLabel("Address", inline: true)
Validator.validate(\.city, with: .notEmpty())
.errorLabel("City", inline: true)
Validator.validate(\.state, with: .notEmpty())
.errorLabel("State", inline: true)
Validator.validate(\.zipCode, with: .notEmpty())
.errorLabel("Zip", inline: true)
Validator.validate(\.sensibleHeatRatio) {
Validator {
Double.greaterThan(0)
Double.lessThanOrEquals(1.0)
}
.optional()
}
.errorLabel("Sensible Heat Ratio", inline: true)
}
}
} }

View File

@@ -3,20 +3,7 @@ import DependenciesMacros
import Fluent import Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
extension DatabaseClient {
@DependencyClient
public struct Rooms: Sendable {
public var create: @Sendable (Room.Create) async throws -> Room
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
}
}
extension DatabaseClient.Rooms: TestDependencyKey { extension DatabaseClient.Rooms: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
@@ -25,9 +12,16 @@ extension DatabaseClient.Rooms: TestDependencyKey {
.init( .init(
create: { request in create: { request in
let model = try request.toModel() let model = try request.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
createMany: { rooms in
try await rooms.asyncMap { request in
let model = try request.toModel()
try await model.validateAndSave(on: database)
return try model.toDTO()
}
},
delete: { id in delete: { id in
guard let model = try await RoomModel.find(id, on: database) else { guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
@@ -41,8 +35,11 @@ extension DatabaseClient.Rooms: TestDependencyKey {
model.rectangularSizes?.removeAll { model.rectangularSizes?.removeAll {
$0.id == rectangularDuctID $0.id == rectangularDuctID
} }
if model.rectangularSizes?.count == 0 {
model.rectangularSizes = nil
}
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
}, },
@@ -61,11 +58,9 @@ extension DatabaseClient.Rooms: TestDependencyKey {
guard let model = try await RoomModel.find(id, on: database) else { guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate()
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
}, },
@@ -88,8 +83,7 @@ extension DatabaseClient.Rooms: TestDependencyKey {
extension Room.Create { extension Room.Create {
func toModel() throws(ValidationError) -> RoomModel { func toModel() throws -> RoomModel {
try validate()
return .init( return .init(
name: name, name: name,
heatingLoad: heatingLoad, heatingLoad: heatingLoad,
@@ -99,57 +93,6 @@ extension Room.Create {
projectID: projectID projectID: projectID
) )
} }
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Room name should not be empty.")
}
guard heatingLoad >= 0 else {
throw ValidationError("Room heating load should not be less than 0.")
}
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
}
}
}
extension Room.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Room name should not be empty.")
}
}
if let heatingLoad {
guard heatingLoad >= 0 else {
throw ValidationError("Room heating load should not be less than 0.")
}
}
if let coolingTotal {
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
}
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
if let registerCount {
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
}
}
}
} }
extension Room { extension Room {
@@ -180,7 +123,7 @@ extension Room {
} }
} }
final class RoomModel: Model, @unchecked Sendable { final class RoomModel: Model, @unchecked Sendable, Validatable {
static let schema = "room" static let schema = "room"
@@ -278,4 +221,38 @@ final class RoomModel: Model, @unchecked Sendable {
} }
var body: some Validation<RoomModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(\.heatingLoad, with: .greaterThanOrEquals(0))
.errorLabel("Heating Load", inline: true)
Validator.validate(\.coolingTotal, with: .greaterThanOrEquals(0))
.errorLabel("Cooling Total", inline: true)
Validator.validate(\.coolingSensible, with: Double.greaterThanOrEquals(0).optional())
.errorLabel("Cooling Sensible", inline: true)
Validator.validate(\.registerCount, with: .greaterThanOrEquals(1))
.errorLabel("Register Count", inline: true)
Validator.validate(\.rectangularSizes)
}
}
}
extension Room.RectangularSize: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
Validator.validate(\.register, with: Int.greaterThanOrEquals(1).optional())
.errorLabel("Register", inline: true)
Validator.validate(\.height, with: Int.greaterThanOrEquals(1))
.errorLabel("Height", inline: true)
}
}
} }

View File

@@ -0,0 +1,41 @@
import Foundation
extension Sequence {
// Taken from: https://forums.swift.org/t/are-there-any-kind-of-asyncmap-method-for-a-normal-sequence/77354/7
func asyncMap<Result: Sendable>(
_ transform: @escaping @Sendable (Element) async throws -> Result
) async rethrows -> [Result] where Element: Sendable {
try await withThrowingTaskGroup(of: (Int, Result).self) { group in
var i = 0
var iterator = self.makeIterator()
var results = [Result?]()
results.reserveCapacity(underestimatedCount)
func submitTask() throws {
try Task.checkCancellation()
if let element = iterator.next() {
results.append(nil)
group.addTask { [i] in try await (i, transform(element)) }
i += 1
}
}
// Add initial tasks
for _ in 0..<ProcessInfo.processInfo.processorCount {
try submitTask()
}
// Submit more tasks as results complete
while let (index, result) = try await group.next() {
results[index] = result
try submitTask()
}
return results.compactMap { $0 }
}
}
}

View File

@@ -3,19 +3,7 @@ import DependenciesMacros
import Fluent import Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
extension DatabaseClient {
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (TrunkSize.Create) async throws -> TrunkSize
public var delete: @Sendable (TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [TrunkSize]
public var get: @Sendable (TrunkSize.ID) async throws -> TrunkSize?
public var update:
@Sendable (TrunkSize.ID, TrunkSize.Update) async throws ->
TrunkSize
}
}
extension DatabaseClient.TrunkSizes: TestDependencyKey { extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
@@ -23,12 +11,12 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
try request.validate() // try request.validate()
let trunk = request.toModel() let trunk = request.toModel()
var roomProxies = [TrunkSize.RoomProxy]() var roomProxies = [TrunkSize.RoomProxy]()
try await trunk.save(on: database) try await trunk.validateAndSave(on: database)
for (roomID, registers) in request.rooms { for (roomID, registers) in request.rooms {
guard let room = try await RoomModel.find(roomID, on: database) else { guard let room = try await RoomModel.find(roomID, on: database) else {
@@ -40,7 +28,7 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
registers: registers, registers: registers,
type: request.type type: request.type
) )
try await model.save(on: database) try await model.validateAndSave(on: database)
roomProxies.append( roomProxies.append(
.init(room: try room.toDTO(), registers: registers) .init(room: try room.toDTO(), registers: registers)
) )
@@ -50,7 +38,9 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
id: trunk.requireID(), id: trunk.requireID(),
projectID: trunk.$project.id, projectID: trunk.$project.id,
type: .init(rawValue: trunk.type)!, type: .init(rawValue: trunk.type)!,
rooms: roomProxies rooms: roomProxies,
height: trunk.height,
name: trunk.name
) )
}, },
delete: { id in delete: { id in
@@ -91,7 +81,7 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
else { else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate() // try updates.validate()
try await model.applyUpdates(updates, on: database) try await model.applyUpdates(updates, on: database)
return try model.toDTO() return try model.toDTO()
} }
@@ -101,17 +91,6 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
extension TrunkSize.Create { extension TrunkSize.Create {
func validate() throws(ValidationError) {
guard rooms.count > 0 else {
throw ValidationError("Trunk size should have associated rooms / registers.")
}
if let height {
guard height > 0 else {
throw ValidationError("Trunk size height should be greater than 0.")
}
}
}
func toModel() -> TrunkModel { func toModel() -> TrunkModel {
.init( .init(
projectID: projectID, projectID: projectID,
@@ -122,21 +101,6 @@ extension TrunkSize.Create {
} }
} }
extension TrunkSize.Update {
func validate() throws(ValidationError) {
if let rooms {
guard rooms.count > 0 else {
throw ValidationError("Trunk size should have associated rooms / registers.")
}
}
if let height {
guard height > 0 else {
throw ValidationError("Trunk size height should be greater than 0.")
}
}
}
}
extension TrunkSize { extension TrunkSize {
struct Migrate: AsyncMigration { struct Migrate: AsyncMigration {
@@ -211,15 +175,22 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
} }
func toDTO() throws -> TrunkSize.RoomProxy { func toDTO() throws -> TrunkSize.RoomProxy {
// guard let room = try await RoomModel.find($room.id, on: database) else {
// throw NotFoundError()
// }
return .init( return .init(
room: try room.toDTO(), room: try room.toDTO(),
registers: registers registers: registers
) )
} }
}
extension TrunkRoomModel: Validatable {
var body: some Validation<TrunkRoomModel> {
Validator.validate(\.registers) {
[Int].notEmpty()
ForEachValidator {
Int.greaterThanOrEquals(1)
}
}
}
} }
final class TrunkModel: Model, @unchecked Sendable { final class TrunkModel: Model, @unchecked Sendable {
@@ -261,19 +232,6 @@ final class TrunkModel: Model, @unchecked Sendable {
} }
func toDTO() throws -> TrunkSize { func toDTO() throws -> TrunkSize {
// let rooms = try await withThrowingTaskGroup(of: TrunkSize.RoomProxy.self) { group in
// for room in self.rooms {
// group.addTask {
// try await room.toDTO(on: database)
// }
// }
//
// return try await group.reduce(into: [TrunkSize.RoomProxy]()) {
// $0.append($1)
// }
//
// }
let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) { let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
$0.append(try $1.toDTO()) $0.append(try $1.toDTO())
} }
@@ -303,7 +261,7 @@ final class TrunkModel: Model, @unchecked Sendable {
self.name = name self.name = name
} }
if hasChanges { if hasChanges {
try await self.save(on: database) try await self.validateAndSave(on: database)
} }
guard let updateRooms = updates.rooms else { guard let updateRooms = updates.rooms else {
@@ -324,7 +282,7 @@ final class TrunkModel: Model, @unchecked Sendable {
currRoom.registers = registers currRoom.registers = registers
} }
if currRoom.hasChanges { if currRoom.hasChanges {
try await currRoom.save(on: database) try await currRoom.validateAndSave(on: database)
} }
} else { } else {
database.logger.debug("CREATING NEW TrunkRoomModel") database.logger.debug("CREATING NEW TrunkRoomModel")
@@ -351,19 +309,27 @@ final class TrunkModel: Model, @unchecked Sendable {
} }
} }
extension TrunkModel: Validatable {
var body: some Validation<TrunkModel> {
Validator.accumulating {
Validator.validate(\.height, with: Int.greaterThan(0).optional())
.errorLabel("Height", inline: true)
Validator.validate(\.name, with: String.notEmpty().optional())
.errorLabel("Name", inline: true)
}
}
}
extension Array where Element == TrunkModel { extension Array where Element == TrunkModel {
func toDTO() throws -> [TrunkSize] { func toDTO() throws -> [TrunkSize] {
// try await withThrowingTaskGroup(of: TrunkSize.self) { group in
// for model in self {
// group.addTask {
// try await model.toDTO(on: database)
// }
// }
return try reduce(into: [TrunkSize]()) { return try reduce(into: [TrunkSize]()) {
$0.append(try $1.toDTO()) $0.append(try $1.toDTO())
} }
} }
// }
} }

View File

@@ -0,0 +1,20 @@
import ManualDCore
import Validations
// Declaring this in seperate file because some Vapor imports
// have same name's and this was easiest solution.
extension User.Create: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
Validator.validate(\.email, with: .email())
.errorLabel("Email", inline: true)
Validator.validate(\.password.count, with: .greaterThanOrEquals(8))
.errorLabel("Password Count", inline: true)
Validator.validate(\.confirmPassword, with: .equals(password))
.mapError(ValidationError("Confirm password does not match."))
.errorLabel("Confirm Password", inline: true)
}
}
}

View File

@@ -1,30 +1,19 @@
import Dependencies import Dependencies
import DependenciesMacros import DependenciesMacros
import Fluent import Fluent
import Foundation
import ManualDCore import ManualDCore
import Vapor import Validations
extension DatabaseClient { extension DatabaseClient.UserProfiles: TestDependencyKey {
@DependencyClient
public struct UserProfile: Sendable {
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
public var delete: @Sendable (User.Profile.ID) async throws -> Void
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
}
}
extension DatabaseClient.UserProfile: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { profile in create: { profile in
try profile.validate()
let model = profile.toModel() let model = profile.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -48,10 +37,9 @@ extension DatabaseClient.UserProfile: TestDependencyKey {
guard let model = try await UserProfileModel.find(id, on: database) else { guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate()
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -61,30 +49,6 @@ extension DatabaseClient.UserProfile: TestDependencyKey {
extension User.Profile.Create { extension User.Profile.Create {
func validate() throws(ValidationError) {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
func toModel() -> UserProfileModel { func toModel() -> UserProfileModel {
.init( .init(
userID: userID, userID: userID,
@@ -100,47 +64,6 @@ extension User.Profile.Create {
} }
} }
extension User.Profile.Update {
func validate() throws(ValidationError) {
if let firstName {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
}
if let lastName {
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
}
if let companyName {
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
}
}
extension User.Profile { extension User.Profile {
struct Migrate: AsyncMigration { struct Migrate: AsyncMigration {
@@ -281,3 +204,31 @@ final class UserProfileModel: Model, @unchecked Sendable {
} }
} }
extension UserProfileModel: Validatable {
var body: some Validation<UserProfileModel> {
Validator.accumulating {
Validator.validate(\.firstName, with: .notEmpty())
.errorLabel("First Name", inline: true)
Validator.validate(\.lastName, with: .notEmpty())
.errorLabel("Last Name", inline: true)
Validator.validate(\.companyName, with: .notEmpty())
.errorLabel("Company", inline: true)
Validator.validate(\.streetAddress, with: .notEmpty())
.errorLabel("Address", inline: true)
Validator.validate(\.city, with: .notEmpty())
.errorLabel("City", inline: true)
Validator.validate(\.state, with: .notEmpty())
.errorLabel("State", inline: true)
Validator.validate(\.zipCode, with: .notEmpty())
.errorLabel("Zip", inline: true)
}
}
}

View File

@@ -1,28 +1,17 @@
import Dependencies import Dependencies
import DependenciesMacros import DependenciesMacros
import Fluent import Fluent
import Foundation
import ManualDCore import ManualDCore
import Vapor import Vapor
extension DatabaseClient {
@DependencyClient
public struct Users: Sendable {
public var create: @Sendable (User.Create) async throws -> User
public var delete: @Sendable (User.ID) async throws -> Void
public var get: @Sendable (User.ID) async throws -> User?
public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void
// public var token: @Sendable (User.ID) async throws -> User.Token
}
}
extension DatabaseClient.Users: TestDependencyKey { extension DatabaseClient.Users: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
try request.validate()
let model = try request.toModel() let model = try request.toModel()
try await model.save(on: database) try await model.save(on: database)
return try model.toDTO() return try model.toDTO()
@@ -46,6 +35,11 @@ extension DatabaseClient.Users: TestDependencyKey {
throw NotFoundError() throw NotFoundError()
} }
// Verify the password matches the user's hashed password.
guard try user.verifyPassword(request.password) else {
throw Abort(.unauthorized)
}
let token: User.Token let token: User.Token
// Check if there's a user token // Check if there's a user token
@@ -126,21 +120,8 @@ extension User {
extension User.Create { extension User.Create {
func toModel() throws -> UserModel { func toModel() throws -> UserModel {
try validate()
return try .init(email: email, passwordHash: User.hashPassword(password)) return try .init(email: email, passwordHash: User.hashPassword(password))
} }
func validate() throws {
guard !email.isEmpty else {
throw ValidationError("Email should not be empty")
}
guard password.count > 8 else {
throw ValidationError("Password should be more than 8 characters long.")
}
guard password == confirmPassword else {
throw ValidationError("Passwords do not match.")
}
}
} }
final class UserModel: Model, @unchecked Sendable { final class UserModel: Model, @unchecked Sendable {

View File

@@ -1,175 +0,0 @@
// import Dependencies
// import DependenciesMacros
// import Fluent
// import Foundation
// import ManualDCore
//
// extension DatabaseClient {
// @DependencyClient
// public struct RectangularDuct: Sendable {
// public var create:
// @Sendable (DuctSizing.RectangularDuct.Create) async throws -> DuctSizing.RectangularDuct
// public var delete: @Sendable (DuctSizing.RectangularDuct.ID) async throws -> Void
// public var fetch: @Sendable (Room.ID) async throws -> [DuctSizing.RectangularDuct]
// public var get:
// @Sendable (DuctSizing.RectangularDuct.ID) async throws -> DuctSizing.RectangularDuct?
// public var update:
// @Sendable (DuctSizing.RectangularDuct.ID, DuctSizing.RectangularDuct.Update) async throws ->
// DuctSizing.RectangularDuct
// }
// }
//
// extension DatabaseClient.RectangularDuct: TestDependencyKey {
// public static let testValue = Self()
//
// public static func live(database: any Database) -> Self {
// .init(
// create: { request in
// try request.validate()
// let model = request.toModel()
// try await model.save(on: database)
// return try model.toDTO()
// },
// delete: { id in
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
// throw NotFoundError()
// }
// try await model.delete(on: database)
// },
// fetch: { roomID in
// try await RectangularDuctModel.query(on: database)
// .with(\.$room)
// .filter(\.$room.$id == roomID)
// .all()
// .map { try $0.toDTO() }
// },
// get: { id in
// try await RectangularDuctModel.find(id, on: database)
// .map { try $0.toDTO() }
// },
// update: { id, updates in
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
// throw NotFoundError()
// }
// try updates.validate()
// model.applyUpdates(updates)
// if model.hasChanges {
// try await model.save(on: database)
// }
// return try model.toDTO()
// }
// )
// }
// }
//
// extension DuctSizing.RectangularDuct.Create {
//
// func validate() throws(ValidationError) {
// guard height > 0 else {
// throw ValidationError("Rectangular duct size height should be greater than 0.")
// }
// if let register {
// guard register > 0 else {
// throw ValidationError("Rectangular duct size register should be greater than 0.")
// }
// }
// }
//
// func toModel() -> RectangularDuctModel {
// .init(roomID: roomID, height: height)
// }
// }
//
// extension DuctSizing.RectangularDuct.Update {
//
// func validate() throws(ValidationError) {
// if let height {
// guard height > 0 else {
// throw ValidationError("Rectangular duct size height should be greater than 0.")
// }
// }
// if let register {
// guard register > 0 else {
// throw ValidationError("Rectangular duct size register should be greater than 0.")
// }
// }
// }
// }
//
// extension DuctSizing.RectangularDuct {
// struct Migrate: AsyncMigration {
// let name = "CreateRectangularDuct"
//
// func prepare(on database: any Database) async throws {
// try await database.schema(RectangularDuctModel.schema)
// .id()
// .field("register", .int8)
// .field("height", .int8, .required)
// .field("roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade))
// .field("createdAt", .datetime)
// .field("updatedAt", .datetime)
// .create()
// }
//
// func revert(on database: any Database) async throws {
// try await database.schema(RectangularDuctModel.schema).delete()
// }
// }
// }
//
// final class RectangularDuctModel: Model, @unchecked Sendable {
//
// static let schema = "rectangularDuct"
//
// @ID(key: .id)
// var id: UUID?
//
// @Parent(key: "roomID")
// var room: RoomModel
//
// @Field(key: "height")
// var height: Int
//
// @Field(key: "register")
// var register: Int?
//
// @Timestamp(key: "createdAt", on: .create, format: .iso8601)
// var createdAt: Date?
//
// @Timestamp(key: "updatedAt", on: .update, format: .iso8601)
// var updatedAt: Date?
//
// init() {}
//
// init(
// id: UUID? = nil,
// roomID: Room.ID,
// register: Int? = nil,
// height: Int
// ) {
// self.id = id
// $room.id = roomID
// self.register = register
// self.height = height
// }
//
// func toDTO() throws -> DuctSizing.RectangularDuct {
// return try .init(
// id: requireID(),
// roomID: $room.id,
// register: register,
// height: height,
// createdAt: createdAt!,
// updatedAt: updatedAt!
// )
// }
//
// func applyUpdates(_ updates: DuctSizing.RectangularDuct.Update) {
// if let height = updates.height, height != self.height {
// self.height = height
// }
// if let register = updates.register, register != self.register {
// self.register = register
// }
// }
// }

View File

@@ -1,74 +0,0 @@
import Dependencies
import DependenciesMacros
import Foundation
extension DependencyValues {
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public var env: @Sendable () throws -> EnvVars {
get { self[EnvClient.self].env }
set { self[EnvClient.self].env = newValue }
}
}
@DependencyClient
struct EnvClient: Sendable {
public var env: @Sendable () throws -> EnvVars
}
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String
public init(
pandocPath: String = "/usr/bin/pandoc",
pdfEngine: String = "weasyprint"
) {
self.pandocPath = pandocPath
self.pdfEngine = pdfEngine
}
enum CodingKeys: String, CodingKey {
case pandocPath = "PANDOC_PATH"
case pdfEngine = "PDF_ENGINE"
}
}
extension EnvClient: DependencyKey {
static let testValue = Self()
static let liveValue = Self(env: {
// Convert default values into a dictionary.
let defaults =
(try? encoder.encode(EnvVars()))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
// Merge the default values with values found in process environment.
let assigned = defaults.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
return (try? JSONSerialization.data(withJSONObject: assigned))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? .init()
})
}
private let encoder: JSONEncoder = {
JSONEncoder()
}()
private let decoder: JSONDecoder = {
JSONDecoder()
}()

View File

@@ -0,0 +1,100 @@
import Dependencies
import DependenciesMacros
import Foundation
extension DependencyValues {
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public var environment: EnvVars {
get { self[EnvVars.self] }
set { self[EnvVars.self] = newValue }
}
}
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String
/// The postgres hostname, used for production database connection.
public let postgresHostname: String?
/// The postgres username, used for production database connection.
public let postgresUsername: String?
/// The postgres password, used for production database connection.
public let postgresPassword: String?
/// The postgres database, used for production database connection.
public let postgresDatabase: String?
/// The path to the sqlite database, used for development database connection.
public let sqlitePath: String?
public init(
pandocPath: String = "/usr/bin/pandoc",
pdfEngine: String = "weasyprint",
postgresHostname: String? = "localhost",
postgresUsername: String? = "vapor",
postgresPassword: String? = "super-secret",
postgresDatabase: String? = "vapor",
sqlitePath: String? = "db.sqlite"
) {
self.pandocPath = pandocPath
self.pdfEngine = pdfEngine
self.postgresHostname = postgresHostname
self.postgresUsername = postgresUsername
self.postgresPassword = postgresPassword
self.postgresDatabase = postgresDatabase
self.sqlitePath = sqlitePath
}
enum CodingKeys: String, CodingKey {
case pandocPath = "PANDOC_PATH"
case pdfEngine = "PDF_ENGINE"
case postgresHostname = "POSTGRES_HOSTNAME"
case postgresUsername = "POSTGRES_USERNAME"
case postgresPassword = "POSTGRES_PASSWORD"
case postgresDatabase = "POSTGRES_DATABASE"
case sqlitePath = "SQLITE_PATH"
}
public static func live(_ env: [String: String] = ProcessInfo.processInfo.environment) -> Self {
// Convert default values into a dictionary.
let defaults =
(try? encoder.encode(EnvVars()))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
// Merge the default values with values found in process environment.
let assigned = defaults.merging(env, uniquingKeysWith: { $1 })
return (try? JSONSerialization.data(withJSONObject: assigned))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? .init()
}
}
extension EnvVars: TestDependencyKey {
public static let testValue = Self()
}
private let encoder: JSONEncoder = {
JSONEncoder()
}()
private let decoder: JSONDecoder = {
JSONDecoder()
}()

View File

@@ -4,6 +4,7 @@ import Foundation
import Vapor import Vapor
extension DependencyValues { extension DependencyValues {
/// Dependency used for file operations.
public var fileClient: FileClient { public var fileClient: FileClient {
get { self[FileClient.self] } get { self[FileClient.self] }
set { self[FileClient.self] = newValue } set { self[FileClient.self] = newValue }
@@ -14,9 +15,27 @@ extension DependencyValues {
public struct FileClient: Sendable { public struct FileClient: Sendable {
public typealias OnCompleteHandler = @Sendable () async throws -> Void public typealias OnCompleteHandler = @Sendable () async throws -> Void
public var writeFile: @Sendable (String, String) async throws -> Void /// Write contents to a file.
public var removeFile: @Sendable (String) async throws -> Void ///
public var streamFile: @Sendable (String, @escaping OnCompleteHandler) async throws -> Response /// > Warning: This will overwrite a file if it exists.
public var writeFile: @Sendable (_ contents: String, _ path: String) async throws -> Void
/// Remove a file.
public var removeFile: @Sendable (_ path: String) async throws -> Void
/// Stream a file.
public var streamFile:
@Sendable (_ path: String, @escaping OnCompleteHandler) async throws -> Response
/// Stream a file at the given path.
///
/// - Paramters:
/// - path: The path to the file to stream.
/// - onComplete: Completion handler to run when done streaming the file.
public func streamFile(
at path: String,
onComplete: @escaping OnCompleteHandler = {}
) async throws -> Response {
try await streamFile(path, onComplete)
}
} }
extension FileClient: TestDependencyKey { extension FileClient: TestDependencyKey {

View File

@@ -11,12 +11,12 @@ extension Snapshotting where Value == (any HTML), Format == String {
} }
} }
extension Snapshotting where Value == String, Format == String { // extension Snapshotting where Value == String, Format == String {
public static var html: Snapshotting { // public static var html: Snapshotting {
var snapshotting = SimplySnapshotting.lines // var snapshotting = SimplySnapshotting.lines
.pullback { $0 } // .pullback { $0 }
//
snapshotting.pathExtension = "html" // snapshotting.pathExtension = "html"
return snapshotting // return snapshotting
} // }
} // }

View File

@@ -46,10 +46,6 @@ extension TrunkSize {
} }
} }
extension ComponentPressureLosses {
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
}
extension Array where Element == EffectiveLengthGroup { extension Array where Element == EffectiveLengthGroup {
var totalEffectiveLength: Int { var totalEffectiveLength: Int {
reduce(0) { $0 + $1.effectiveLength } reduce(0) { $0 + $1.effectiveLength }
@@ -101,16 +97,16 @@ func roundSize(_ size: Double) throws -> Int {
} }
} }
func velocity(cfm: Int, roundSize: Int) -> Int { func velocity(cfm: ManualDClient.CFM, roundSize: Int) -> Int {
let cfm = Double(cfm) let cfm = Double(cfm.rawValue)
let roundSize = Double(roundSize) let roundSize = Double(roundSize)
let velocity = cfm / (pow(roundSize / 24, 2) * 3.14) let velocity = cfm / (pow(roundSize / 24, 2) * 3.14)
return Int(round(velocity)) return Int(round(velocity))
} }
func flexSize(_ request: ManualDClient.DuctSizeRequest) throws -> Int { func flexSize(_ cfm: ManualDClient.CFM, _ frictionRate: Double) throws -> Int {
let cfm = pow(Double(request.designCFM), 0.4) let cfm = pow(Double(cfm.rawValue), 0.4)
let fr = pow(request.frictionRate / 1.76, 0.2) let fr = pow(frictionRate / 1.76, 0.2)
let size = 0.55 * (cfm / fr) let size = 0.55 * (cfm / fr)
return try roundSize(size) return try roundSize(size)
} }

View File

@@ -2,8 +2,10 @@ import Dependencies
import DependenciesMacros import DependenciesMacros
import Logging import Logging
import ManualDCore import ManualDCore
import Tagged
extension DependencyValues { extension DependencyValues {
/// Dependency that performs manual-d duct sizing calculations.
public var manualD: ManualDClient { public var manualD: ManualDClient {
get { self[ManualDClient.self] } get { self[ManualDClient.self] }
set { self[ManualDClient.self] = newValue } set { self[ManualDClient.self] = newValue }
@@ -15,12 +17,61 @@ extension DependencyValues {
/// ///
@DependencyClient @DependencyClient
public struct ManualDClient: Sendable { public struct ManualDClient: Sendable {
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRate
public var totalEquivalentLength: @Sendable (TotalEquivalentLengthRequest) async throws -> Int
public var rectangularSize:
@Sendable (RectangularSizeRequest) async throws -> RectangularSizeResponse
/// Calculates the duct size for the given cfm and friction rate.
public var ductSize: @Sendable (CFM, DesignFrictionRate) async throws -> DuctSize
/// Calculates the design friction rate for the given request.
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRate
/// Calculates the equivalent rectangular size for the given round duct and rectangular height.
public var rectangularSize: @Sendable (RoundSize, Height) async throws -> RectangularSize
/// Calculates the duct size for the given cfm and friction rate.
///
/// - Paramaters:
/// - designCFM: The design cfm for the duct.
/// - designFrictionRate: The design friction rate for the system.
public func ductSize(
cfm designCFM: Int,
frictionRate designFrictionRate: Double
) async throws -> DuctSize {
try await ductSize(.init(rawValue: designCFM), .init(rawValue: designFrictionRate))
}
/// Calculates the duct size for the given cfm and friction rate.
///
/// - Paramaters:
/// - designCFM: The design cfm for the duct.
/// - designFrictionRate: The design friction rate for the system.
public func ductSize(
cfm designCFM: Double,
frictionRate designFrictionRate: Double
) async throws -> DuctSize {
try await ductSize(.init(rawValue: Int(designCFM)), .init(rawValue: designFrictionRate))
}
/// Calculates the equivalent rectangular size for the given round duct and rectangular height.
///
/// - Paramaters:
/// - roundSize: The round duct size.
/// - height: The rectangular height of the duct.
public func rectangularSize(
round roundSize: RoundSize,
height: Height
) async throws -> RectangularSize {
try await rectangularSize(roundSize, height)
}
/// Calculates the equivalent rectangular size for the given round duct and rectangular height.
///
/// - Paramaters:
/// - roundSize: The round duct size.
/// - height: The rectangular height of the duct.
public func rectangularSize(
round roundSize: Int,
height: Int
) async throws -> RectangularSize {
try await rectangularSize(.init(rawValue: roundSize), .init(rawValue: height))
}
} }
extension ManualDClient: TestDependencyKey { extension ManualDClient: TestDependencyKey {
@@ -28,21 +79,20 @@ extension ManualDClient: TestDependencyKey {
} }
extension ManualDClient { extension ManualDClient {
/// A name space for tags used by the ManualDClient.
public struct DuctSizeRequest: Codable, Equatable, Sendable { public enum Tag {
public let designCFM: Int public enum CFM {}
public let frictionRate: Double public enum DesignFrictionRate {}
public enum Height {}
public init( public enum Round {}
designCFM: Int,
frictionRate: Double
) {
self.designCFM = designCFM
self.frictionRate = frictionRate
}
} }
public struct DuctSizeResponse: Codable, Equatable, Sendable { public typealias CFM = Tagged<Tag.CFM, Int>
public typealias DesignFrictionRate = Tagged<Tag.DesignFrictionRate, Double>
public typealias Height = Tagged<Tag.Height, Int>
public typealias RoundSize = Tagged<Tag.Round, Int>
public struct DuctSize: Codable, Equatable, Sendable {
public let calculatedSize: Double public let calculatedSize: Double
public let finalSize: Int public let finalSize: Int
@@ -66,58 +116,20 @@ extension ManualDClient {
public let externalStaticPressure: Double public let externalStaticPressure: Double
public let componentPressureLosses: [ComponentPressureLoss] public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int public let totalEquivalentLength: Int
public init( public init(
externalStaticPressure: Double, externalStaticPressure: Double,
componentPressureLosses: [ComponentPressureLoss], componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int totalEquivalentLength: Int
) { ) {
self.externalStaticPressure = externalStaticPressure self.externalStaticPressure = externalStaticPressure
self.componentPressureLosses = componentPressureLosses self.componentPressureLosses = componentPressureLosses
self.totalEffectiveLength = totalEffectiveLength self.totalEquivalentLength = totalEquivalentLength
} }
} }
public struct FrictionRateResponse: Codable, Equatable, Sendable { public struct RectangularSize: Codable, Equatable, Sendable {
public let availableStaticPressure: Double
public let frictionRate: Double
public init(availableStaticPressure: Double, frictionRate: Double) {
self.availableStaticPressure = availableStaticPressure
self.frictionRate = frictionRate
}
}
public struct TotalEquivalentLengthRequest: Codable, Equatable, Sendable {
public let trunkLengths: [Int]
public let runoutLengths: [Int]
public let effectiveLengthGroups: [EffectiveLengthGroup]
public init(
trunkLengths: [Int],
runoutLengths: [Int],
effectiveLengthGroups: [EffectiveLengthGroup]
) {
self.trunkLengths = trunkLengths
self.runoutLengths = runoutLengths
self.effectiveLengthGroups = effectiveLengthGroups
}
}
public struct RectangularSizeRequest: Codable, Equatable, Sendable {
public let roundSize: Int
public let height: Int
public init(round roundSize: Int, height: Int) {
self.roundSize = roundSize
self.height = height
}
}
public struct RectangularSizeResponse: Codable, Equatable, Sendable {
public let height: Int public let height: Int
public let width: Int public let width: Int

View File

@@ -4,41 +4,47 @@ import ManualDCore
extension ManualDClient: DependencyKey { extension ManualDClient: DependencyKey {
public static let liveValue: Self = .init( public static let liveValue: Self = .init(
ductSize: { request in ductSize: { cfm, frictionRate in
guard request.designCFM > 0 else { guard cfm > 0 else {
throw ManualDError(message: "Design CFM should be greater than 0.") throw ManualDError(message: "Design CFM should be greater than 0.")
} }
let fr = pow(request.frictionRate, 0.5) let fr = pow(frictionRate.rawValue, 0.5)
let ductulatorSize = pow(Double(request.designCFM) / (3.12 * fr), 0.38) let ductulatorSize = pow(Double(cfm.rawValue) / (3.12 * fr), 0.38)
let finalSize = try roundSize(ductulatorSize) let finalSize = try roundSize(ductulatorSize)
let flexSize = try flexSize(request) let flexSize = try flexSize(cfm, frictionRate.rawValue)
return .init( return .init(
calculatedSize: ductulatorSize, calculatedSize: ductulatorSize,
finalSize: finalSize, finalSize: finalSize,
flexSize: flexSize, flexSize: flexSize,
velocity: velocity(cfm: request.designCFM, roundSize: finalSize) velocity: velocity(cfm: cfm, roundSize: finalSize)
) )
}, },
frictionRate: { request in frictionRate: { request in
// Ensure the total effective length is greater than 0. // Ensure the total effective length is greater than 0.
guard request.totalEffectiveLength > 0 else { guard request.totalEquivalentLength > 0 else {
throw ManualDError(message: "Total Effective Length should be greater than 0.") throw ManualDError(message: "Total Effective Length should be greater than 0.")
} }
let totalComponentLosses = request.componentPressureLosses.total let totalComponentLosses = request.componentPressureLosses.total
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength) let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEquivalentLength)
return .init(availableStaticPressure: availableStaticPressure, value: frictionRate) return .init(
availableStaticPressure: availableStaticPressure,
value: frictionRate
)
}, },
totalEquivalentLength: { request in // totalEquivalentLength: { request in
let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 } // let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 } // let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
let groupLengths = request.effectiveLengthGroups.totalEffectiveLength // let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
return trunkLengths + runoutLengths + groupLengths // return trunkLengths + runoutLengths + groupLengths
}, // },
rectangularSize: { request in rectangularSize: { round, height in
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height) let width = (Double.pi * (pow(Double(round.rawValue) / 2.0, 2.0))) / Double(height.rawValue)
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven))) return .init(
height: height.rawValue,
width: Int(width.rounded(.toNearestOrEven))
)
} }
) )
} }

View File

@@ -1,6 +1,10 @@
import Dependencies import Dependencies
import Foundation import Foundation
/// Represents component pressure losses used in the friction rate worksheet.
///
/// These are items such as filter, evaporator-coils, balance-dampers, etc. that
/// need to be overcome by the system fan.
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable { public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
public let id: UUID public let id: UUID
@@ -44,7 +48,7 @@ extension ComponentPressureLoss {
self.value = value self.value = value
} }
// Return's commonly used default component pressure losses. /// Commonly used default component pressure losses.
public static func `default`(projectID: Project.ID) -> [Self] { public static func `default`(projectID: Project.ID) -> [Self] {
[ [
.init(projectID: projectID, name: "supply-outlet", value: 0.03), .init(projectID: projectID, name: "supply-outlet", value: 0.03),
@@ -75,20 +79,7 @@ extension Array where Element == ComponentPressureLoss {
} }
} }
public typealias ComponentPressureLosses = [String: Double]
#if DEBUG #if DEBUG
extension ComponentPressureLosses {
public static var mock: Self {
[
"evaporator-coil": 0.2,
"filter": 0.1,
"supply-outlet": 0.03,
"return-grille": 0.03,
"balancing-damper": 0.03,
]
}
}
extension Array where Element == ComponentPressureLoss { extension Array where Element == ComponentPressureLoss {
public static func mock(projectID: Project.ID) -> Self { public static func mock(projectID: Project.ID) -> Self {

View File

@@ -1,215 +0,0 @@
import Dependencies
import Foundation
// TODO: Not sure how to model effective length groups in the database.
// thinking perhaps just have a 'data' field that encoded / decodes
// to swift types??
public struct EffectiveLength: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let name: String
public let type: EffectiveLengthType
public let straightLengths: [Int]
public let groups: [Group]
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
projectID: Project.ID,
name: String,
type: EffectiveLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EffectiveLength.Group],
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension EffectiveLength {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let name: String
public let type: EffectiveLengthType
public let straightLengths: [Int]
public let groups: [Group]
public init(
projectID: Project.ID,
name: String,
type: EffectiveLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EffectiveLength.Group]
) {
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let type: EffectiveLengthType?
public let straightLengths: [Int]?
public let groups: [Group]?
public init(
name: String? = nil,
type: EffectiveLength.EffectiveLengthType? = nil,
straightLengths: [Int]? = nil,
groups: [EffectiveLength.Group]? = nil
) {
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
case `return`
case supply
}
public struct Group: Codable, Equatable, Sendable {
public let group: Int
public let letter: String
public let value: Double
public let quantity: Int
public init(
group: Int,
letter: String,
value: Double,
quantity: Int = 1
) {
self.group = group
self.letter = letter
self.value = value
self.quantity = quantity
}
}
public struct MaxContainer: Codable, Equatable, Sendable {
public let supply: EffectiveLength?
public let `return`: EffectiveLength?
public var total: Double? {
guard let supply else { return nil }
guard let `return` else { return nil }
return supply.totalEquivalentLength + `return`.totalEquivalentLength
}
public init(supply: EffectiveLength? = nil, return: EffectiveLength? = nil) {
self.supply = supply
self.return = `return`
}
}
}
extension EffectiveLength {
public var totalEquivalentLength: Double {
straightLengths.reduce(into: 0.0) { $0 += Double($1) }
+ groups.totalEquivalentLength
}
}
extension Array where Element == EffectiveLength.Group {
public var totalEquivalentLength: Double {
reduce(into: 0.0) {
$0 += ($1.value * Double($1.quantity))
}
}
}
#if DEBUG
extension EffectiveLength {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Supply - 1",
type: .supply,
straightLengths: [10, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 30, quantity: 1),
.init(group: 3, letter: "a", value: 10, quantity: 1),
.init(group: 12, letter: "a", value: 10, quantity: 1),
],
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Return - 1",
type: .return,
straightLengths: [10, 20, 5],
groups: [
.init(group: 5, letter: "a", value: 10),
.init(group: 6, letter: "a", value: 15, quantity: 1),
.init(group: 7, letter: "a", value: 20, quantity: 1),
],
createdAt: now,
updatedAt: now
),
]
}
public static let mocks: [Self] = [
.init(
id: UUID(0),
projectID: UUID(0),
name: "Test Supply - 1",
type: .supply,
straightLengths: [10, 20, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 15, quantity: 2),
.init(group: 3, letter: "c", value: 10, quantity: 1),
],
createdAt: Date(),
updatedAt: Date()
),
.init(
id: UUID(1),
projectID: UUID(0),
name: "Test Return - 1",
type: .return,
straightLengths: [10, 20, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 15, quantity: 2),
.init(group: 3, letter: "c", value: 10, quantity: 1),
],
createdAt: Date(),
updatedAt: Date()
),
]
}
#endif

View File

@@ -1,5 +1,7 @@
import Foundation import Foundation
// TODO: These are not used, should they be removed??
// TODO: Add other description / label for items that have same group & letter, but // TODO: Add other description / label for items that have same group & letter, but
// different effective length. // different effective length.
public struct EffectiveLengthGroup: Codable, Equatable, Sendable { public struct EffectiveLengthGroup: Codable, Equatable, Sendable {

View File

@@ -1,6 +1,10 @@
import Dependencies import Dependencies
import Foundation import Foundation
/// Represents the equipment information for a project.
///
/// This is used in the friction rate worksheet and sizing ducts. It holds on to items
/// such as the target static pressure, cooling CFM, and heating CFM for the project.
public struct EquipmentInfo: Codable, Equatable, Identifiable, Sendable { public struct EquipmentInfo: Codable, Equatable, Identifiable, Sendable {
public let id: UUID public let id: UUID
public let projectID: Project.ID public let projectID: Project.ID

View File

@@ -0,0 +1,239 @@
import Dependencies
import Foundation
/// Represents the equivalent length of a single duct path.
///
/// These consist of both straight lengths of duct / trunks, as well as the
/// equivalent length of duct fittings. They are used to determine the worst
/// case total equivalent length of duct that the system fan has to move air
/// through.
///
/// There can be many equivalent lengths saved for a project, however the only
/// ones that matter in most calculations are the longest supply path and the
/// the longest return path.
///
/// It is required that project has at least one equivalent length saved for
/// the supply and one saved for return, otherwise duct sizes can not be calculated.
public struct EquivalentLength: Codable, Equatable, Identifiable, Sendable {
/// The id of the equivalent length.
public let id: UUID
/// The project that this equivalent length is associated with.
public let projectID: Project.ID
/// A unique name / label for this equivalent length.
public let name: String
/// The type (supply or return) of the equivalent length.
public let type: EffectiveLengthType
/// The straight lengths of duct for this equivalent length.
public let straightLengths: [Int]
/// The fitting groups associated with this equivalent length.
public let groups: [FittingGroup]
/// When this equivalent length was created in the database.
public let createdAt: Date
/// When this equivalent length was updated in the database.
public let updatedAt: Date
public init(
id: UUID,
projectID: Project.ID,
name: String,
type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EquivalentLength.FittingGroup],
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension EquivalentLength {
/// Represents the data needed to create a new ``EquivalentLength`` in the database.
public struct Create: Codable, Equatable, Sendable {
/// The project that this equivalent length is associated with.
public let projectID: Project.ID
/// A unique name / label for this equivalent length.
public let name: String
/// The type (supply or return) of the equivalent length.
public let type: EffectiveLengthType
/// The straight lengths of duct for this equivalent length.
public let straightLengths: [Int]
/// The fitting groups associated with this equivalent length.
public let groups: [FittingGroup]
public init(
projectID: Project.ID,
name: String,
type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EquivalentLength.FittingGroup]
) {
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
/// Represents the data needed to update an ``EquivalentLength`` in the database.
///
/// Only the supplied fields are updated.
public struct Update: Codable, Equatable, Sendable {
/// A unique name / label for this equivalent length.
public let name: String?
/// The type (supply or return) of the equivalent length.
public let type: EffectiveLengthType?
/// The straight lengths of duct for this equivalent length.
public let straightLengths: [Int]?
/// The fitting groups associated with this equivalent length.
public let groups: [FittingGroup]?
public init(
name: String? = nil,
type: EquivalentLength.EffectiveLengthType? = nil,
straightLengths: [Int]? = nil,
groups: [EquivalentLength.FittingGroup]? = nil
) {
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
/// Represents the type of equivalent length, either supply or return.
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
case `return`
case supply
}
/// Represents a Manual-D fitting group.
///
/// These are defined by Manual-D and convert different types of fittings into
/// an equivalent length of straight duct.
public struct FittingGroup: Codable, Equatable, Sendable {
/// The fitting group number.
public let group: Int
/// The fitting group letter.
public let letter: String
/// The equivalent length of the fitting.
public let value: Double
/// The quantity of the fittings in the path.
public let quantity: Int
public init(
group: Int,
letter: String,
value: Double,
quantity: Int = 1
) {
self.group = group
self.letter = letter
self.value = value
self.quantity = quantity
}
}
// TODO: Should these not be optional and we just throw an error or return nil from
// a database query.
/// Represents the max ``EquivalentLength``'s for a project.
///
/// Calculating the duct sizes for a project requires there to be a max supply
/// and a max return equivalent length, so this container represents those values
/// when they exist in the database.
public struct MaxContainer: Codable, Equatable, Sendable {
/// The longest supply equivalent length.
public let supply: EquivalentLength?
/// The longest return equivalent length.
public let `return`: EquivalentLength?
public init(supply: EquivalentLength? = nil, return: EquivalentLength? = nil) {
self.supply = supply
self.return = `return`
}
public var totalEquivalentLength: Double? {
guard let supply else { return nil }
guard let `return` else { return nil }
return supply.totalEquivalentLength + `return`.totalEquivalentLength
}
}
}
extension EquivalentLength {
/// The calculated total equivalent length.
///
/// This is the sum of all the straigth lengths and fitting groups (with quantities).
public var totalEquivalentLength: Double {
straightLengths.reduce(into: 0.0) { $0 += Double($1) }
+ groups.totalEquivalentLength
}
}
extension Array where Element == EquivalentLength.FittingGroup {
/// The calculated total equivalent length for the fitting groups.
public var totalEquivalentLength: Double {
reduce(into: 0.0) {
$0 += ($1.value * Double($1.quantity))
}
}
}
#if DEBUG
extension EquivalentLength {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Supply - 1",
type: .supply,
straightLengths: [10, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 30, quantity: 1),
.init(group: 3, letter: "a", value: 10, quantity: 1),
.init(group: 12, letter: "a", value: 10, quantity: 1),
],
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Return - 1",
type: .return,
straightLengths: [10, 20, 5],
groups: [
.init(group: 5, letter: "a", value: 10),
.init(group: 6, letter: "a", value: 15, quantity: 1),
.init(group: 7, letter: "a", value: 20, quantity: 1),
],
createdAt: now,
updatedAt: now
),
]
}
}
#endif

View File

@@ -1,4 +1,17 @@
import Foundation import Foundation
import Tagged
extension Tagged where RawValue == Double {
public func string(digits: Int = 2) -> String {
rawValue.string(digits: digits)
}
}
extension Tagged where RawValue == Int {
public func string() -> String {
rawValue.string()
}
}
extension Double { extension Double {

View File

@@ -1,8 +1,14 @@
/// Holds onto values returned when calculating the design /// Holds onto values returned when calculating the design
/// friction rate for a project. /// friction rate for a project.
///
/// **NOTE:** This is not stored in the database, it is calculated on the fly.
public struct FrictionRate: Codable, Equatable, Sendable { public struct FrictionRate: Codable, Equatable, Sendable {
/// The available static pressure is the equipment's design static pressure
/// minus the ``ComponentPressureLoss``es for the project.
public let availableStaticPressure: Double public let availableStaticPressure: Double
/// The calculated design friction rate value.
public let value: Double public let value: Double
/// Whether the design friction rate is within a valid range.
public var hasErrors: Bool { error != nil } public var hasErrors: Bool { error != nil }
public init( public init(
@@ -13,6 +19,7 @@ public struct FrictionRate: Codable, Equatable, Sendable {
self.value = value self.value = value
} }
/// The error if the design friction rate is out of a valid range.
public var error: FrictionRateError? { public var error: FrictionRateError? {
if value >= 0.18 { if value >= 0.18 {
return .init( return .init(
@@ -37,8 +44,13 @@ public struct FrictionRate: Codable, Equatable, Sendable {
} }
} }
/// Represents an error when the ``FrictionRate`` is out of a valid range.
///
/// This holds onto the reason for the error as well as possible resolutions.
public struct FrictionRateError: Error, Equatable, Sendable { public struct FrictionRateError: Error, Equatable, Sendable {
/// The reason for the error.
public let reason: String public let reason: String
/// The possible resolutions to the error.
public let resolutions: [String] public let resolutions: [String]
public init( public init(

View File

@@ -1,16 +1,29 @@
import Dependencies import Dependencies
import Foundation import Foundation
/// Represents a single duct design project / system.
///
/// Holds items such as project name and address.
public struct Project: Codable, Equatable, Identifiable, Sendable { public struct Project: Codable, Equatable, Identifiable, Sendable {
/// The unique ID of the project.
public let id: UUID public let id: UUID
/// The name of the project.
public let name: String public let name: String
/// The street address of the project.
public let streetAddress: String public let streetAddress: String
/// The city of the project.
public let city: String public let city: String
/// The state of the project.
public let state: String public let state: String
/// The zip code of the project.
public let zipCode: String public let zipCode: String
/// The global sensible heat ratio for the project.
///
/// **NOTE:** This is used for calculating the sensible cooling load for rooms.
public let sensibleHeatRatio: Double? public let sensibleHeatRatio: Double?
/// When the project was created in the database.
public let createdAt: Date public let createdAt: Date
/// When the project was updated in the database.
public let updatedAt: Date public let updatedAt: Date
public init( public init(
@@ -37,14 +50,20 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
} }
extension Project { extension Project {
/// Represents the data needed to create a new project.
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
/// The name of the project.
public let name: String public let name: String
/// The street address of the project.
public let streetAddress: String public let streetAddress: String
/// The city of the project.
public let city: String public let city: String
/// The state of the project.
public let state: String public let state: String
/// The zip code of the project.
public let zipCode: String public let zipCode: String
/// The global sensible heat ratio for the project.
public let sensibleHeatRatio: Double? public let sensibleHeatRatio: Double?
public init( public init(
@@ -64,11 +83,19 @@ extension Project {
} }
} }
/// Represents steps that are completed in order to calculate the duct sizes
/// for a project.
///
/// This is primarily used on the web pages to display errors or color of the
/// different steps of a project.
public struct CompletedSteps: Codable, Equatable, Sendable { public struct CompletedSteps: Codable, Equatable, Sendable {
/// Whether there is ``EquipmentInfo`` for a project.
public let equipmentInfo: Bool public let equipmentInfo: Bool
/// Whether there are ``Room``'s for a project.
public let rooms: Bool public let rooms: Bool
/// Whether there are ``EquivalentLength``'s for a project.
public let equivalentLength: Bool public let equivalentLength: Bool
/// Whether there is a ``FrictionRate`` for a project.
public let frictionRate: Bool public let frictionRate: Bool
public init( public init(
@@ -84,20 +111,30 @@ extension Project {
} }
} }
/// Represents project details loaded from the database.
///
/// This is generally used to perform duct sizing calculations for the
/// project, once all the steps have been completed.
public struct Detail: Codable, Equatable, Sendable { public struct Detail: Codable, Equatable, Sendable {
/// The project.
public let project: Project public let project: Project
/// The component pressure losses for the project.
public let componentLosses: [ComponentPressureLoss] public let componentLosses: [ComponentPressureLoss]
/// The equipment info for the project.
public let equipmentInfo: EquipmentInfo public let equipmentInfo: EquipmentInfo
public let equivalentLengths: [EffectiveLength] /// The equivalent lengths for the project.
public let equivalentLengths: [EquivalentLength]
/// The rooms in the project.
public let rooms: [Room] public let rooms: [Room]
/// The trunk sizes in the project.
public let trunks: [TrunkSize] public let trunks: [TrunkSize]
public init( public init(
project: Project, project: Project,
componentLosses: [ComponentPressureLoss], componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo, equipmentInfo: EquipmentInfo,
equivalentLengths: [EffectiveLength], equivalentLengths: [EquivalentLength],
rooms: [Room], rooms: [Room],
trunks: [TrunkSize] trunks: [TrunkSize]
) { ) {
@@ -110,13 +147,22 @@ extension Project {
} }
} }
/// Represents fields that can be updated for a project that has already been created.
///
/// Only fields that are supplied get updated in the database.
public struct Update: Codable, Equatable, Sendable { public struct Update: Codable, Equatable, Sendable {
/// The name of the project.
public let name: String? public let name: String?
/// The street address of the project.
public let streetAddress: String? public let streetAddress: String?
/// The city of the project.
public let city: String? public let city: String?
/// The state of the project.
public let state: String? public let state: String?
/// The zip code of the project.
public let zipCode: String? public let zipCode: String?
/// The global sensible heat ratio for the project.
public let sensibleHeatRatio: Double? public let sensibleHeatRatio: Double?
public init( public init(

View File

@@ -1,16 +1,37 @@
import Dependencies import Dependencies
import Foundation import Foundation
/// Represents a room in a project.
///
/// This contains data such as the heating and cooling load for the
/// room, the number of registers in the room, and any rectangular
/// duct size calculations stored for the room.
public struct Room: Codable, Equatable, Identifiable, Sendable { public struct Room: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the room.
public let id: UUID public let id: UUID
/// The project this room is associated with.
public let projectID: Project.ID public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double public let coolingTotal: Double
/// An optional sensible cooling load for the room.
///
/// **NOTE:** This is generally not set, but calculated from the project wide
/// sensible heat ratio.
public let coolingSensible: Double? public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int public let registerCount: Int
/// The rectangular duct size calculations for a room.
///
/// **NOTE:** These are optionally set after the round sizes have been calculate
/// for a room.
public let rectangularSizes: [RectangularSize]? public let rectangularSizes: [RectangularSize]?
/// When the room was created in the database.
public let createdAt: Date public let createdAt: Date
/// When the room was updated in the database.
public let updatedAt: Date public let updatedAt: Date
public init( public init(
@@ -39,13 +60,19 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
} }
extension Room { extension Room {
/// Represents the data required to create a new room for a project.
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
/// The project this room is associated with.
public let projectID: Project.ID public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double public let coolingTotal: Double
/// An optional sensible cooling load for the room.
public let coolingSensible: Double? public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int public let registerCount: Int
public init( public init(
@@ -65,10 +92,17 @@ extension Room {
} }
} }
/// Represents a rectangular size calculation that is stored in the
/// database for a given room.
///
/// These are done after the round duct sizes have been calculated and
/// can be used to calculate the equivalent rectangular size for a given run.
public struct RectangularSize: Codable, Equatable, Identifiable, Sendable { public struct RectangularSize: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the rectangular size.
public let id: UUID public let id: UUID
/// The register the rectangular size is associated with.
public let register: Int? public let register: Int?
/// The height of the rectangular size, the width gets calculated.
public let height: Int public let height: Int
public init( public init(
@@ -82,12 +116,21 @@ extension Room {
} }
} }
/// Represents field that can be updated on a room after it's been created in the database.
///
/// Only fields that are supplied get updated.
public struct Update: Codable, Equatable, Sendable { public struct Update: Codable, Equatable, Sendable {
/// A unique name for the room in the project.
public let name: String? public let name: String?
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double? public let heatingLoad: Double?
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double? public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double? public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int? public let registerCount: Int?
/// The rectangular duct size calculations for a room.
public let rectangularSizes: [RectangularSize]? public let rectangularSizes: [RectangularSize]?
public init( public init(
@@ -120,14 +163,20 @@ extension Room {
extension Array where Element == Room { extension Array where Element == Room {
/// The sum of heating loads for an array of rooms.
public var totalHeatingLoad: Double { public var totalHeatingLoad: Double {
reduce(into: 0) { $0 += $1.heatingLoad } reduce(into: 0) { $0 += $1.heatingLoad }
} }
/// The sum of total cooling loads for an array of rooms.
public var totalCoolingLoad: Double { public var totalCoolingLoad: Double {
reduce(into: 0) { $0 += $1.coolingTotal } reduce(into: 0) { $0 += $1.coolingTotal }
} }
/// The sum of sensible cooling loads for an array of rooms.
///
/// - Parameters:
/// - shr: The project wide sensible heat ratio.
public func totalCoolingSensible(shr: Double) -> Double { public func totalCoolingSensible(shr: Double) -> Double {
reduce(into: 0) { reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr) let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
@@ -139,38 +188,6 @@ extension Array where Element == Room {
#if DEBUG #if DEBUG
extension Room { extension Room {
public static let mocks = [
Room(
id: UUID(0),
projectID: UUID(0),
name: "Kitchen",
heatingLoad: 12345,
coolingTotal: 1234,
registerCount: 2,
createdAt: Date(),
updatedAt: Date()
),
Room(
id: UUID(1),
projectID: UUID(1),
name: "Bedroom - 1",
heatingLoad: 12345,
coolingTotal: 1456,
registerCount: 1,
createdAt: Date(),
updatedAt: Date()
),
Room(
id: UUID(2),
projectID: UUID(2),
name: "Family Room",
heatingLoad: 12345,
coolingTotal: 1673,
registerCount: 3,
createdAt: Date(),
updatedAt: Date()
),
]
public static func mock(projectID: Project.ID) -> [Self] { public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid @Dependency(\.uuid) var uuid

View File

@@ -1,270 +0,0 @@
import CasePathsCore
import Foundation
@preconcurrency import URLRouting
extension SiteRoute {
/// Represents api routes.
///
/// The routes return json as opposed to view routes that return html.
public enum Api: Sendable, Equatable {
case project(Self.ProjectRoute)
case room(Self.RoomRoute)
case equipment(Self.EquipmentRoute)
case componentLoss(Self.ComponentLossRoute)
public static let rootPath = Path {
"api"
"v1"
}
public static let router = OneOf {
Route(.case(Self.project)) {
rootPath
ProjectRoute.router
}
Route(.case(Self.room)) {
rootPath
RoomRoute.router
}
Route(.case(Self.equipment)) {
rootPath
EquipmentRoute.router
}
Route(.case(Self.componentLoss)) {
rootPath
ComponentLossRoute.router
}
}
}
}
extension SiteRoute.Api {
public enum ProjectRoute: Sendable, Equatable {
case create(Project.Create)
case delete(id: Project.ID)
case detail(id: Project.ID, route: DetailRoute)
case get(id: Project.ID)
case index
static let rootPath = "projects"
public static let router = OneOf {
Route(.case(Self.create)) {
Path { rootPath }
Method.post
Body(.json(Project.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
Project.ID.parser()
}
Method.delete
}
Route(.case(Self.get(id:))) {
Path {
rootPath
Project.ID.parser()
}
Method.get
}
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.detail(id:route:))) {
Path {
rootPath
Project.ID.parser()
}
DetailRoute.router
}
}
}
}
extension SiteRoute.Api.ProjectRoute {
public enum DetailRoute: Equatable, Sendable {
case index
case completedSteps
static let rootPath = "details"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.completedSteps)) {
Path {
rootPath
"completed"
}
Method.get
}
}
}
}
extension SiteRoute.Api {
public enum RoomRoute: Sendable, Equatable {
case create(Room.Create)
case delete(id: Room.ID)
case get(id: Room.ID)
static let rootPath = "rooms"
public static let router = OneOf {
Route(.case(Self.create)) {
Path { rootPath }
Method.post
Body(.json(Room.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
Room.ID.parser()
}
Method.delete
}
Route(.case(Self.get(id:))) {
Path {
rootPath
Room.ID.parser()
}
Method.get
}
}
}
}
extension SiteRoute.Api {
public enum EquipmentRoute: Sendable, Equatable {
case create(EquipmentInfo.Create)
case delete(id: EquipmentInfo.ID)
case fetch(projectID: Project.ID)
case get(id: EquipmentInfo.ID)
static let rootPath = "equipment"
public static let router = OneOf {
Route(.case(Self.create)) {
Path { rootPath }
Method.post
Body(.json(EquipmentInfo.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
EquipmentInfo.ID.parser()
}
Method.delete
}
Route(.case(Self.fetch(projectID:))) {
Path { rootPath }
Method.get
Query {
Field("projectID") { Project.ID.parser() }
}
}
Route(.case(Self.get(id:))) {
Path {
rootPath
EquipmentInfo.ID.parser()
}
Method.get
}
}
}
}
extension SiteRoute.Api {
public enum ComponentLossRoute: Sendable, Equatable {
case create(ComponentPressureLoss.Create)
case delete(id: ComponentPressureLoss.ID)
case fetch(projectID: Project.ID)
case get(id: ComponentPressureLoss.ID)
static let rootPath = "componentLoss"
public static let router = OneOf {
Route(.case(Self.create)) {
Path { rootPath }
Method.post
Body(.json(ComponentPressureLoss.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
ComponentPressureLoss.ID.parser()
}
Method.delete
}
Route(.case(Self.fetch(projectID:))) {
Path { rootPath }
Method.get
Query {
Field("projectID") { Project.ID.parser() }
}
}
Route(.case(Self.get(id:))) {
Path {
rootPath
ComponentPressureLoss.ID.parser()
}
Method.get
}
}
}
}
extension SiteRoute.Api {
public enum EffectiveLengthRoute: Equatable, Sendable {
case create(EffectiveLength.Create)
case delete(id: EffectiveLength.ID)
case fetch(projectID: Project.ID)
case get(id: EffectiveLength.ID)
static let rootPath = "effectiveLength"
public static let router = OneOf {
Route(.case(Self.create)) {
Path {
rootPath
"create"
}
Method.post
Body(.json(EffectiveLength.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
EffectiveLength.ID.parser()
}
Method.delete
}
Route(.case(Self.fetch(projectID:))) {
Path {
rootPath
}
Method.get
Query {
Field("projectID") { Project.ID.parser() }
}
}
Route(.case(Self.get(id:))) {
Path {
rootPath
EffectiveLength.ID.parser()
}
Method.get
}
}
}
}

View File

@@ -5,14 +5,10 @@ import Foundation
public enum SiteRoute: Equatable, Sendable { public enum SiteRoute: Equatable, Sendable {
case api(Self.Api)
case health case health
case view(Self.View) case view(Self.View)
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.api)) {
SiteRoute.Api.router
}
Route(.case(Self.health)) { Route(.case(Self.health)) {
Path { "health" } Path { "health" }
Method.get Method.get

View File

@@ -394,11 +394,11 @@ extension SiteRoute.View.ProjectRoute {
} }
public enum EquivalentLengthRoute: Equatable, Sendable { public enum EquivalentLengthRoute: Equatable, Sendable {
case delete(id: EffectiveLength.ID) case delete(id: EquivalentLength.ID)
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil) case field(FieldType, style: EquivalentLength.EffectiveLengthType? = nil)
case index case index
case submit(FormStep) case submit(FormStep)
case update(EffectiveLength.ID, StepThree) case update(EquivalentLength.ID, StepThree)
static let rootPath = "effective-lengths" static let rootPath = "effective-lengths"
@@ -406,7 +406,7 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.delete(id:))) { Route(.case(Self.delete(id:))) {
Path { Path {
rootPath rootPath
EffectiveLength.ID.parser() EquivalentLength.ID.parser()
} }
Method.delete Method.delete
} }
@@ -424,7 +424,7 @@ extension SiteRoute.View.ProjectRoute {
Field("type") { FieldType.parser() } Field("type") { FieldType.parser() }
Optionally { Optionally {
Field("style", default: nil) { Field("style", default: nil) {
EffectiveLength.EffectiveLengthType.parser() EquivalentLength.EffectiveLengthType.parser()
} }
} }
} }
@@ -437,16 +437,16 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.update)) { Route(.case(Self.update)) {
Path { Path {
rootPath rootPath
EffectiveLength.ID.parser() EquivalentLength.ID.parser()
} }
Method.patch Method.patch
Body { Body {
FormData { FormData {
Optionally { Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() } Field("id", default: nil) { EquivalentLength.ID.parser() }
} }
Field("name", .string) Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() } Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many { Many {
Field("straightLengths") { Field("straightLengths") {
Int.parser() Int.parser()
@@ -490,10 +490,10 @@ extension SiteRoute.View.ProjectRoute {
Body { Body {
FormData { FormData {
Optionally { Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() } Field("id", default: nil) { EquivalentLength.ID.parser() }
} }
Field("name", .string) Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() } Field("type") { EquivalentLength.EffectiveLengthType.parser() }
} }
.map(.memberwise(StepOne.init)) .map(.memberwise(StepOne.init))
} }
@@ -505,10 +505,10 @@ extension SiteRoute.View.ProjectRoute {
Body { Body {
FormData { FormData {
Optionally { Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() } Field("id", default: nil) { EquivalentLength.ID.parser() }
} }
Field("name", .string) Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() } Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many { Many {
Field("straightLengths") { Field("straightLengths") {
Int.parser() Int.parser()
@@ -525,10 +525,10 @@ extension SiteRoute.View.ProjectRoute {
Body { Body {
FormData { FormData {
Optionally { Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() } Field("id", default: nil) { EquivalentLength.ID.parser() }
} }
Field("name", .string) Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() } Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many { Many {
Field("straightLengths") { Field("straightLengths") {
Int.parser() Int.parser()
@@ -567,22 +567,22 @@ extension SiteRoute.View.ProjectRoute {
} }
public struct StepOne: Codable, Equatable, Sendable { public struct StepOne: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID? public let id: EquivalentLength.ID?
public let name: String public let name: String
public let type: EffectiveLength.EffectiveLengthType public let type: EquivalentLength.EffectiveLengthType
} }
public struct StepTwo: Codable, Equatable, Sendable { public struct StepTwo: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID? public let id: EquivalentLength.ID?
public let name: String public let name: String
public let type: EffectiveLength.EffectiveLengthType public let type: EquivalentLength.EffectiveLengthType
public let straightLengths: [Int] public let straightLengths: [Int]
public init( public init(
id: EffectiveLength.ID? = nil, id: EquivalentLength.ID? = nil,
name: String, name: String,
type: EffectiveLength.EffectiveLengthType, type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int] straightLengths: [Int]
) { ) {
self.id = id self.id = id
@@ -593,9 +593,9 @@ extension SiteRoute.View.ProjectRoute {
} }
public struct StepThree: Codable, Equatable, Sendable { public struct StepThree: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID? public let id: EquivalentLength.ID?
public let name: String public let name: String
public let type: EffectiveLength.EffectiveLengthType public let type: EquivalentLength.EffectiveLengthType
public let straightLengths: [Int] public let straightLengths: [Int]
public let groupGroups: [Int] public let groupGroups: [Int]
public let groupLetters: [String] public let groupLetters: [String]

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
/// Represents supported color themes for the website.
public enum Theme: String, CaseIterable, Codable, Equatable, Sendable { public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
case aqua case aqua
case cupcake case cupcake
@@ -13,6 +14,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
case retro case retro
case synthwave case synthwave
/// Represents dark color themes.
public static let darkThemes = [ public static let darkThemes = [
Self.aqua, Self.aqua,
Self.cyberpunk, Self.cyberpunk,
@@ -22,6 +24,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
Self.synthwave, Self.synthwave,
] ]
/// Represents light color themes.
public static let lightThemes = [ public static let lightThemes = [
Self.cupcake, Self.cupcake,
Self.light, Self.light,

View File

@@ -1,14 +1,23 @@
import Dependencies import Dependencies
import Foundation import Foundation
// Represents the database model. /// Represents trunk calculations for a project.
///
/// These are used to size trunk ducts / runs for multiple rooms or registers.
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable { public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
/// The unique identifier of the trunk size.
public let id: UUID public let id: UUID
/// The project the trunk size is for.
public let projectID: Project.ID public let projectID: Project.ID
/// The type of the trunk size (supply or return).
public let type: TrunkType public let type: TrunkType
/// The rooms / registers associated with the trunk size.
public let rooms: [RoomProxy] public let rooms: [RoomProxy]
/// An optional rectangular height used to calculate the equivalent
/// rectangular size of the trunk.
public let height: Int? public let height: Int?
/// An optional name / label used for identifying the trunk.
public let name: String? public let name: String?
public init( public init(
@@ -29,12 +38,19 @@ public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
} }
extension TrunkSize { extension TrunkSize {
/// Represents the data needed to create a new ``TrunkSize`` in the database.
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
/// The project the trunk size is for.
public let projectID: Project.ID public let projectID: Project.ID
/// The type of the trunk size (supply or return).
public let type: TrunkType public let type: TrunkType
/// The rooms / registers associated with the trunk size.
public let rooms: [Room.ID: [Int]] public let rooms: [Room.ID: [Int]]
/// An optional rectangular height used to calculate the equivalent
/// rectangular size of the trunk.
public let height: Int? public let height: Int?
/// An optional name / label used for identifying the trunk.
public let name: String? public let name: String?
public init( public init(
@@ -52,11 +68,19 @@ extension TrunkSize {
} }
} }
/// Represents the fields that can be updated on a ``TrunkSize`` in the database.
///
/// Only supplied fields are updated.
public struct Update: Codable, Equatable, Sendable { public struct Update: Codable, Equatable, Sendable {
/// The type of the trunk size (supply or return).
public let type: TrunkType? public let type: TrunkType?
/// The rooms / registers associated with the trunk size.
public let rooms: [Room.ID: [Int]]? public let rooms: [Room.ID: [Int]]?
/// An optional rectangular height used to calculate the equivalent
/// rectangular size of the trunk.
public let height: Int? public let height: Int?
/// An optional name / label used for identifying the trunk.
public let name: String? public let name: String?
public init( public init(
@@ -72,18 +96,29 @@ extension TrunkSize {
} }
} }
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable { /// A container / wrapper around a ``Room`` that is used with a ``TrunkSize``.
///
/// This is needed because a room can have multiple registers and it is possible
/// that a trunk does not serve all registers in that room.
@dynamicMemberLookup
public struct RoomProxy: Codable, Equatable, Sendable {
public var id: Room.ID { room.id } /// The room associated with the ``TrunkSize``.
public let room: Room public let room: Room
/// The specific room registers associated with the ``TrunkSize``.
public let registers: [Int] public let registers: [Int]
public init(room: Room, registers: [Int]) { public init(room: Room, registers: [Int]) {
self.room = room self.room = room
self.registers = registers self.registers = registers
} }
public subscript<T>(dynamicMember keyPath: KeyPath<Room, T>) -> T {
room[keyPath: keyPath]
}
} }
/// Represents the type of a ``TrunkSize``, either supply or return.
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable { public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return` case `return`
case supply case supply

View File

@@ -1,12 +1,17 @@
import Dependencies import Dependencies
import Foundation import Foundation
// FIX: Remove username. /// Represents a user of the site.
///
public struct User: Codable, Equatable, Identifiable, Sendable { public struct User: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the user.
public let id: UUID public let id: UUID
/// The user's email address.
public let email: String public let email: String
/// When the user was created in the database.
public let createdAt: Date public let createdAt: Date
/// When the user was updated in the database.
public let updatedAt: Date public let updatedAt: Date
public init( public init(
@@ -23,10 +28,14 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
} }
extension User { extension User {
/// Represents the data required to create a new user.
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String public let email: String
/// The password for the user.
public let password: String public let password: String
/// The password confirmation, must match the password.
public let confirmPassword: String public let confirmPassword: String
public init( public init(
@@ -40,9 +49,13 @@ extension User {
} }
} }
/// Represents data required to login a user.
public struct Login: Codable, Equatable, Sendable { public struct Login: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String public let email: String
/// The password for the user.
public let password: String public let password: String
/// An optional page / route to navigate to after logging in the user.
public let next: String? public let next: String?
public init(email: String, password: String, next: String? = nil) { public init(email: String, password: String, next: String? = nil) {
@@ -52,10 +65,13 @@ extension User {
} }
} }
/// Represents a user session token, for a logged in user.
public struct Token: Codable, Equatable, Identifiable, Sendable { public struct Token: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the token.
public let id: UUID public let id: UUID
/// The user id the token is for.
public let userID: User.ID public let userID: User.ID
/// The token value.
public let value: String public let value: String
public init(id: UUID, userID: User.ID, value: String) { public init(id: UUID, userID: User.ID, value: String) {

View File

@@ -2,19 +2,32 @@ import Dependencies
import Foundation import Foundation
extension User { extension User {
/// Represents a user's profile. Which contains extra information about a user of the site.
public struct Profile: Codable, Equatable, Identifiable, Sendable { public struct Profile: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the profile
public let id: UUID public let id: UUID
/// The user id the profile is for.
public let userID: User.ID public let userID: User.ID
/// The user's first name.
public let firstName: String public let firstName: String
/// The user's last name.
public let lastName: String public let lastName: String
/// The user's company name.
public let companyName: String public let companyName: String
/// The user's street address.
public let streetAddress: String public let streetAddress: String
/// The user's city.
public let city: String public let city: String
/// The user's state.
public let state: String public let state: String
/// The user's zip code.
public let zipCode: String public let zipCode: String
/// An optional theme that the user prefers.
public let theme: Theme? public let theme: Theme?
/// When the profile was created in the database.
public let createdAt: Date public let createdAt: Date
/// When the profile was updated in the database.
public let updatedAt: Date public let updatedAt: Date
public init( public init(
@@ -49,15 +62,25 @@ extension User {
extension User.Profile { extension User.Profile {
/// Represents the data required to create a user profile.
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
/// The user id the profile is for.
public let userID: User.ID public let userID: User.ID
/// The user's first name.
public let firstName: String public let firstName: String
/// The user's last name.
public let lastName: String public let lastName: String
/// The user's company name.
public let companyName: String public let companyName: String
/// The user's street address.
public let streetAddress: String public let streetAddress: String
/// The user's city.
public let city: String public let city: String
/// The user's state.
public let state: String public let state: String
/// The user's zip code.
public let zipCode: String public let zipCode: String
/// An optional theme that the user prefers.
public let theme: Theme? public let theme: Theme?
public init( public init(
@@ -83,14 +106,25 @@ extension User.Profile {
} }
} }
/// Represents the fields that can be updated on a user's profile.
///
/// Only fields that are supplied get updated.
public struct Update: Codable, Equatable, Sendable { public struct Update: Codable, Equatable, Sendable {
/// The user's first name.
public let firstName: String? public let firstName: String?
/// The user's last name.
public let lastName: String? public let lastName: String?
/// The user's company name.
public let companyName: String? public let companyName: String?
/// The user's street address.
public let streetAddress: String? public let streetAddress: String?
/// The user's city.
public let city: String? public let city: String?
/// The user's state.
public let state: String? public let state: String?
/// The user's zip code.
public let zipCode: String? public let zipCode: String?
/// An optional theme that the user prefers.
public let theme: Theme? public let theme: Theme?
public init( public init(

View File

@@ -1,7 +1,7 @@
import Dependencies import Dependencies
import DependenciesMacros import DependenciesMacros
import Elementary import Elementary
import EnvClient import EnvVars
import FileClient import FileClient
import Foundation import Foundation
import ManualDCore import ManualDCore
@@ -32,8 +32,7 @@ public struct PdfClient: Sendable {
/// - Parameters: /// - Parameters:
/// - request: The project data used to generate the pdf. /// - request: The project data used to generate the pdf.
public func generatePdf(request: Request) async throws -> Response { public func generatePdf(request: Request) async throws -> Response {
let html = try await self.html(request) try await self.generatePdf(request.project.id, html(request))
return try await self.generatePdf(request.project.id, html)
} }
} }
@@ -47,9 +46,8 @@ extension PdfClient: DependencyKey {
}, },
generatePdf: { projectID, html in generatePdf: { projectID, html in
@Dependency(\.fileClient) var fileClient @Dependency(\.fileClient) var fileClient
@Dependency(\.env) var env @Dependency(\.environment) var environment
let envVars = try env()
let baseUrl = "/tmp/\(projectID)" let baseUrl = "/tmp/\(projectID)"
try await fileClient.writeFile(html.render(), "\(baseUrl).html") try await fileClient.writeFile(html.render(), "\(baseUrl).html")
@@ -58,10 +56,10 @@ extension PdfClient: DependencyKey {
let standardOutput = Pipe() let standardOutput = Pipe()
process.standardInput = standardInput process.standardInput = standardInput
process.standardOutput = standardOutput process.standardOutput = standardOutput
process.executableURL = URL(fileURLWithPath: envVars.pandocPath) process.executableURL = URL(fileURLWithPath: environment.pandocPath)
process.arguments = [ process.arguments = [
"\(baseUrl).html", "\(baseUrl).html",
"--pdf-engine=\(envVars.pdfEngine)", "--pdf-engine=\(environment.pdfEngine)",
"--from=html", "--from=html",
"--css=Public/css/pdf.css", "--css=Public/css/pdf.css",
"--output=\(baseUrl).pdf", "--output=\(baseUrl).pdf",
@@ -76,17 +74,26 @@ extension PdfClient: DependencyKey {
} }
extension PdfClient { extension PdfClient {
/// Container for the data required to generate a pdf for a given project. /// Represents the data required to generate a pdf for a given project.
public struct Request: Codable, Equatable, Sendable { public struct Request: Codable, Equatable, Sendable {
/// The project we're generating a pdf for.
public let project: Project public let project: Project
/// The rooms in the project.
public let rooms: [Room] public let rooms: [Room]
/// The component pressure losses for the project.
public let componentLosses: [ComponentPressureLoss] public let componentLosses: [ComponentPressureLoss]
/// The calculated duct sizes for the project.
public let ductSizes: DuctSizes public let ductSizes: DuctSizes
/// The equipment information for the project.
public let equipmentInfo: EquipmentInfo public let equipmentInfo: EquipmentInfo
public let maxSupplyTEL: EffectiveLength /// The max supply equivalent length for the project.
public let maxReturnTEL: EffectiveLength public let maxSupplyTEL: EquivalentLength
/// The max return equivalent length for the project.
public let maxReturnTEL: EquivalentLength
/// The calculated design friction rate for the project.
public let frictionRate: FrictionRate public let frictionRate: FrictionRate
/// The project wide sensible heat ratio.
public let projectSHR: Double public let projectSHR: Double
var totalEquivalentLength: Double { var totalEquivalentLength: Double {
@@ -99,8 +106,8 @@ extension PdfClient {
componentLosses: [ComponentPressureLoss], componentLosses: [ComponentPressureLoss],
ductSizes: DuctSizes, ductSizes: DuctSizes,
equipmentInfo: EquipmentInfo, equipmentInfo: EquipmentInfo,
maxSupplyTEL: EffectiveLength, maxSupplyTEL: EquivalentLength,
maxReturnTEL: EffectiveLength, maxReturnTEL: EquivalentLength,
frictionRate: FrictionRate, frictionRate: FrictionRate,
projectSHR: Double projectSHR: Double
) { ) {
@@ -116,9 +123,12 @@ extension PdfClient {
} }
} }
/// Represents the response after generating a pdf.
public struct Response: Equatable, Sendable { public struct Response: Equatable, Sendable {
/// The path to the html file used to generate the pdf from.
public let htmlPath: String public let htmlPath: String
/// The path to the pdf file.
public let pdfPath: String public let pdfPath: String
public init(htmlPath: String, pdfPath: String) { public init(htmlPath: String, pdfPath: String) {
@@ -134,7 +144,7 @@ extension PdfClient {
let rooms = Room.mock(projectID: project.id) let rooms = Room.mock(projectID: project.id)
let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms) let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms)
let equipmentInfo = EquipmentInfo.mock(projectID: project.id) let equipmentInfo = EquipmentInfo.mock(projectID: project.id)
let equivalentLengths = EffectiveLength.mock(projectID: project.id) let equivalentLengths = EquivalentLength.mock(projectID: project.id)
return .init( return .init(
project: project, project: project,

View File

@@ -2,7 +2,7 @@ import Elementary
import ManualDCore import ManualDCore
struct EffectiveLengthsTable: HTML, Sendable { struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [EffectiveLength] let effectiveLengths: [EquivalentLength]
var body: some HTML<HTMLTag.table> { var body: some HTML<HTMLTag.table> {
table { table {
@@ -41,7 +41,7 @@ struct EffectiveLengthsTable: HTML, Sendable {
} }
struct EffectiveLengthGroupTable: HTML, Sendable { struct EffectiveLengthGroupTable: HTML, Sendable {
let groups: [EffectiveLength.Group] let groups: [EquivalentLength.FittingGroup]
var body: some HTML<HTMLTag.table> { var body: some HTML<HTMLTag.table> {
table { table {

View File

@@ -1,9 +0,0 @@
import Foundation
public struct ProjectClientError: Error {
public let reason: String
public init(_ reason: String) {
self.reason = reason
}
}

View File

@@ -15,12 +15,15 @@ extension DependencyValues {
/// Useful helper utilities for project's. /// Useful helper utilities for project's.
/// ///
/// This is primarily used for implementing logic required to get the needed data /// This is primarily used for implementing logic required to get the needed data
/// for the view controller client to render views. /// for the view controller to render views.
@DependencyClient @DependencyClient
public struct ProjectClient: Sendable { public struct ProjectClient: Sendable {
public var calculateDuctSizes: @Sendable (Project.ID) async throws -> DuctSizes
/// Calculates the room duct sizes for the given project.
public var calculateRoomDuctSizes: public var calculateRoomDuctSizes:
@Sendable (Project.ID) async throws -> [DuctSizes.RoomContainer] @Sendable (Project.ID) async throws -> [DuctSizes.RoomContainer]
/// Calculates the trunk duct sizes for the given project.
public var calculateTrunkDuctSizes: public var calculateTrunkDuctSizes:
@Sendable (Project.ID) async throws -> [DuctSizes.TrunkContainer] @Sendable (Project.ID) async throws -> [DuctSizes.TrunkContainer]
@@ -29,6 +32,13 @@ public struct ProjectClient: Sendable {
public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse
public var generatePdf: @Sendable (Project.ID) async throws -> Response public var generatePdf: @Sendable (Project.ID) async throws -> Response
public func calculateDuctSizes(_ projectID: Project.ID) async throws -> DuctSizes {
.init(
rooms: try await calculateRoomDuctSizes(projectID),
trunks: try await calculateTrunkDuctSizes(projectID)
)
}
} }
extension ProjectClient: TestDependencyKey { extension ProjectClient: TestDependencyKey {
@@ -60,12 +70,12 @@ extension ProjectClient {
public struct FrictionRateResponse: Codable, Equatable, Sendable { public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let componentLosses: [ComponentPressureLoss] public let componentLosses: [ComponentPressureLoss]
public let equivalentLengths: EffectiveLength.MaxContainer public let equivalentLengths: EquivalentLength.MaxContainer
public let frictionRate: FrictionRate? public let frictionRate: FrictionRate?
public init( public init(
componentLosses: [ComponentPressureLoss], componentLosses: [ComponentPressureLoss],
equivalentLengths: EffectiveLength.MaxContainer, equivalentLengths: EquivalentLength.MaxContainer,
frictionRate: FrictionRate? = nil frictionRate: FrictionRate? = nil
) { ) {
self.componentLosses = componentLosses self.componentLosses = componentLosses

View File

@@ -1,7 +1,7 @@
import DatabaseClient import DatabaseClient
import ManualDCore import ManualDCore
extension DatabaseClient.ComponentLoss { extension DatabaseClient.ComponentLosses {
func createDefaults(projectID: Project.ID) async throws { func createDefaults(projectID: Project.ID) async throws {
let defaults = ComponentPressureLoss.Create.default(projectID: projectID) let defaults = ComponentPressureLoss.Create.default(projectID: projectID)

View File

@@ -9,32 +9,18 @@ extension DatabaseClient {
details: Project.Detail details: Project.Detail
) async throws -> (DuctSizes, DuctSizeSharedRequest) { ) async throws -> (DuctSizes, DuctSizeSharedRequest) {
let (rooms, shared) = try await calculateRoomDuctSizes(details: details) let (rooms, shared) = try await calculateRoomDuctSizes(details: details)
let (trunks, _) = try await calculateTrunkDuctSizes(details: details)
return (.init(rooms: rooms, trunks: trunks), shared)
}
func calculateDuctSizes(
projectID: Project.ID
) async throws -> (DuctSizes, DuctSizeSharedRequest, [Room]) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
let rooms = try await rooms.fetch(projectID)
return try await ( return try await (
manualD.calculateDuctSizes( .init(
rooms: rooms, rooms: rooms,
trunks: trunkSizes.fetch(projectID), trunks: calculateTrunkDuctSizes(details: details, shared: shared)
sharedRequest: shared
), ),
shared, shared
rooms
) )
} }
func calculateRoomDuctSizes( func calculateRoomDuctSizes(
details: Project.Detail details: Project.Detail
) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) { ) async throws -> (rooms: [DuctSizes.RoomContainer], shared: DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD @Dependency(\.manualD) var manualD
let shared = try sharedDuctRequest(details: details) let shared = try sharedDuctRequest(details: details)
@@ -42,54 +28,29 @@ extension DatabaseClient {
return (rooms, shared) return (rooms, shared)
} }
func calculateRoomDuctSizes( func calculateTrunkDuctSizes(
projectID: Project.ID details: Project.Detail,
) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) { shared: DuctSizeSharedRequest? = nil
) async throws -> [DuctSizes.TrunkContainer] {
@Dependency(\.manualD) var manualD @Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID) let sharedRequest: DuctSizeSharedRequest
if let shared {
return try await ( sharedRequest = shared
manualD.calculateRoomSizes( } else {
rooms: rooms.fetch(projectID), sharedRequest = try sharedDuctRequest(details: details)
sharedRequest: shared
),
shared
)
} }
func calculateTrunkDuctSizes( return try await manualD.calculateTrunkSizes(
details: Project.Detail
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try sharedDuctRequest(details: details)
let trunks = try await manualD.calculateTrunkSizes(
rooms: details.rooms, rooms: details.rooms,
trunks: details.trunks, trunks: details.trunks,
sharedRequest: shared sharedRequest: sharedRequest
)
return (trunks, shared)
}
func calculateTrunkDuctSizes(
projectID: Project.ID
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
return try await (
manualD.calculateTrunkSizes(
rooms: rooms.fetch(projectID),
trunks: trunkSizes.fetch(projectID),
sharedRequest: shared
),
shared
) )
} }
func sharedDuctRequest(details: Project.Detail) throws -> DuctSizeSharedRequest { func sharedDuctRequest(details: Project.Detail) throws -> DuctSizeSharedRequest {
let projectSHR = try details.project.ensuredSHR()
guard guard
let dfrResponse = designFrictionRate( let dfrResponse = designFrictionRate(
componentLosses: details.componentLosses, componentLosses: details.componentLosses,
@@ -100,10 +61,6 @@ extension DatabaseClient {
throw ProjectClientError("Project not complete.") throw ProjectClientError("Project not complete.")
} }
guard let projectSHR = details.project.sensibleHeatRatio else {
throw ProjectClientError("Project sensible heat ratio not set.")
}
let ensuredTEL = try dfrResponse.ensureMaxContainer() let ensuredTEL = try dfrResponse.ensureMaxContainer()
return .init( return .init(
@@ -115,40 +72,14 @@ extension DatabaseClient {
) )
} }
func sharedDuctRequest(_ projectID: Project.ID) async throws -> DuctSizeSharedRequest {
guard let dfrResponse = try await designFrictionRate(projectID: projectID) else {
throw ProjectClientError("Project not complete.")
}
let ensuredTEL = try dfrResponse.ensureMaxContainer()
return try await .init(
equipmentInfo: dfrResponse.equipmentInfo,
maxSupplyLength: ensuredTEL.supply,
maxReturnLenght: ensuredTEL.return,
designFrictionRate: dfrResponse.designFrictionRate,
projectSHR: ensuredSHR(projectID)
)
}
// Fetches the project sensible heat ratio or throws an error if it's nil.
func ensuredSHR(_ projectID: Project.ID) async throws -> Double {
guard let projectSHR = try await projects.getSensibleHeatRatio(projectID) else {
throw ProjectClientError("Project sensible heat ratio not set.")
}
return projectSHR
}
// Internal container. // Internal container.
struct DesignFrictionRateResponse: Equatable, Sendable { struct DesignFrictionRateResponse: Equatable, Sendable {
typealias EnsuredTEL = (supply: EffectiveLength, return: EffectiveLength) typealias EnsuredTEL = (supply: EquivalentLength, return: EquivalentLength)
let designFrictionRate: Double let designFrictionRate: Double
let equipmentInfo: EquipmentInfo let equipmentInfo: EquipmentInfo
let telMaxContainer: EffectiveLength.MaxContainer let telMaxContainer: EquivalentLength.MaxContainer
func ensureMaxContainer() throws -> EnsuredTEL { func ensureMaxContainer() throws -> EnsuredTEL {
@@ -167,9 +98,9 @@ extension DatabaseClient {
func designFrictionRate( func designFrictionRate(
componentLosses: [ComponentPressureLoss], componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo, equipmentInfo: EquipmentInfo,
equivalentLengths: EffectiveLength.MaxContainer equivalentLengths: EquivalentLength.MaxContainer
) -> DesignFrictionRateResponse? { ) -> DesignFrictionRateResponse? {
guard let tel = equivalentLengths.total, guard let tel = equivalentLengths.totalEquivalentLength,
componentLosses.count > 0 componentLosses.count > 0
else { return nil } else { return nil }
@@ -183,18 +114,13 @@ extension DatabaseClient {
} }
func designFrictionRate( }
projectID: Project.ID
) async throws -> DesignFrictionRateResponse? {
guard let equipmentInfo = try await equipment.fetch(projectID) else { extension Project {
return nil func ensuredSHR() throws -> Double {
guard let shr = sensibleHeatRatio else {
throw ProjectClientError("Sensible heat ratio not set on project id: \(id)")
} }
return shr
return try await designFrictionRate(
componentLosses: componentLoss.fetch(projectID),
equipmentInfo: equipmentInfo,
equivalentLengths: effectiveLength.fetchMax(projectID)
)
} }
} }

View File

@@ -0,0 +1,53 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
import PdfClient
extension DatabaseClient {
/// Generate a pdf request for the given project.
func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
@Dependency(\.manualD) var manualD
guard let projectDetails = try await projects.detail(projectID) else {
throw ProjectClientError.notFound(.project(projectID))
}
let (ductSizes, shared) = try await calculateDuctSizes(details: projectDetails)
let frictionRateResponse = try await manualD.frictionRate(details: projectDetails)
guard let frictionRate = frictionRateResponse.frictionRate else {
throw ProjectClientError.notFound(.frictionRate(projectID))
}
return .init(
details: projectDetails,
ductSizes: ductSizes,
shared: shared,
frictionRate: frictionRate
)
}
}
extension PdfClient.Request {
fileprivate init(
details: Project.Detail,
ductSizes: DuctSizes,
shared: DuctSizeSharedRequest,
frictionRate: FrictionRate
) {
self.init(
project: details.project,
rooms: details.rooms,
componentLosses: details.componentLosses,
ductSizes: ductSizes,
equipmentInfo: details.equipmentInfo,
maxSupplyTEL: shared.maxSupplyLength,
maxReturnTEL: shared.maxReturnLenght,
frictionRate: frictionRate,
projectSHR: shared.projectSHR
)
}
}

View File

@@ -4,8 +4,8 @@ import ManualDCore
struct DuctSizeSharedRequest { struct DuctSizeSharedRequest {
let equipmentInfo: EquipmentInfo let equipmentInfo: EquipmentInfo
let maxSupplyLength: EffectiveLength let maxSupplyLength: EquivalentLength
let maxReturnLenght: EffectiveLength let maxReturnLenght: EquivalentLength
let designFrictionRate: Double let designFrictionRate: Double
let projectSHR: Double let projectSHR: Double
} }
@@ -52,7 +52,8 @@ extension ManualDClient {
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM) let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM) let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize( let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: sharedRequest.designFrictionRate) cfm: designCFM.value,
frictionRate: sharedRequest.designFrictionRate
) )
for n in 1...room.registerCount { for n in 1...room.registerCount {
@@ -63,7 +64,8 @@ extension ManualDClient {
if let rectangularSize { if let rectangularSize {
let response = try await self.rectangularSize( let response = try await self.rectangularSize(
.init(round: sizes.finalSize, height: rectangularSize.height) round: sizes.finalSize,
height: rectangularSize.height
) )
rectangularWidth = response.width rectangularWidth = response.width
} }
@@ -111,12 +113,14 @@ extension ManualDClient {
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM) let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM) let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize( let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: sharedRequest.designFrictionRate) cfm: designCFM.value,
frictionRate: sharedRequest.designFrictionRate
) )
var width: Int? = nil var width: Int? = nil
if let height = trunk.height { if let height = trunk.height {
let rectangularSize = try await self.rectangularSize( let rectangularSize = try await self.rectangularSize(
.init(round: sizes.finalSize, height: height) round: sizes.finalSize,
height: height
) )
width = rectangularSize.width width = rectangularSize.width
} }
@@ -142,7 +146,7 @@ extension ManualDClient {
extension DuctSizes.SizeContainer { extension DuctSizes.SizeContainer {
init( init(
designCFM: DuctSizes.DesignCFM, designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSizeResponse, sizes: ManualDClient.DuctSize,
height: Int?, height: Int?,
width: Int? width: Int?
) { ) {
@@ -160,7 +164,7 @@ extension DuctSizes.SizeContainer {
init( init(
designCFM: DuctSizes.DesignCFM, designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSizeResponse, sizes: ManualDClient.DuctSize,
rectangularSize: Room.RectangularSize?, rectangularSize: Room.RectangularSize?,
width: Int? width: Int?
) { ) {

View File

@@ -8,7 +8,7 @@ extension ManualDClient {
func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse { func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse {
let maxContainer = details.maxContainer let maxContainer = details.maxContainer
guard let totalEquivalentLength = maxContainer.total else { guard let totalEquivalentLength = maxContainer.totalEquivalentLength else {
return .init(componentLosses: details.componentLosses, equivalentLengths: maxContainer) return .init(componentLosses: details.componentLosses, equivalentLengths: maxContainer)
} }
@@ -19,7 +19,7 @@ extension ManualDClient {
.init( .init(
externalStaticPressure: details.equipmentInfo.staticPressure, externalStaticPressure: details.equipmentInfo.staticPressure,
componentPressureLosses: details.componentLosses, componentPressureLosses: details.componentLosses,
totalEffectiveLength: Int(totalEquivalentLength) totalEquivalentLength: Int(totalEquivalentLength)
) )
) )
) )
@@ -28,15 +28,15 @@ extension ManualDClient {
func frictionRate(projectID: Project.ID) async throws -> ProjectClient.FrictionRateResponse { func frictionRate(projectID: Project.ID) async throws -> ProjectClient.FrictionRateResponse {
@Dependency(\.database) var database @Dependency(\.database) var database
let componentLosses = try await database.componentLoss.fetch(projectID) let componentLosses = try await database.componentLosses.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID) let lengths = try await database.equivalentLengths.fetchMax(projectID)
let equipmentInfo = try await database.equipment.fetch(projectID) let equipmentInfo = try await database.equipment.fetch(projectID)
guard let staticPressure = equipmentInfo?.staticPressure else { guard let staticPressure = equipmentInfo?.staticPressure else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths) return .init(componentLosses: componentLosses, equivalentLengths: lengths)
} }
guard let totalEquivalentLength = lengths.total else { guard let totalEquivalentLength = lengths.totalEquivalentLength else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths) return .init(componentLosses: componentLosses, equivalentLengths: lengths)
} }
@@ -46,8 +46,8 @@ extension ManualDClient {
frictionRate: frictionRate( frictionRate: frictionRate(
.init( .init(
externalStaticPressure: staticPressure, externalStaticPressure: staticPressure,
componentPressureLosses: database.componentLoss.fetch(projectID), componentPressureLosses: componentLosses,
totalEffectiveLength: Int(totalEquivalentLength) totalEquivalentLength: Int(totalEquivalentLength)
) )
) )
) )

View File

@@ -1,7 +1,7 @@
import ManualDCore import ManualDCore
extension Project.Detail { extension Project.Detail {
var maxContainer: EffectiveLength.MaxContainer { var maxContainer: EquivalentLength.MaxContainer {
.init( .init(
supply: equivalentLengths.filter({ $0.type == .supply }) supply: equivalentLengths.filter({ $0.type == .supply })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength }) .sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })

View File

@@ -15,18 +15,21 @@ extension ProjectClient: DependencyKey {
@Dependency(\.fileClient) var fileClient @Dependency(\.fileClient) var fileClient
return .init( return .init(
calculateDuctSizes: { projectID in
try await database.calculateDuctSizes(projectID: projectID).0
},
calculateRoomDuctSizes: { projectID in calculateRoomDuctSizes: { projectID in
try await database.calculateRoomDuctSizes(projectID: projectID).0 guard let details = try await database.projects.detail(projectID) else {
throw ProjectClientError.notFound(.project(projectID))
}
return try await database.calculateRoomDuctSizes(details: details).rooms
}, },
calculateTrunkDuctSizes: { projectID in calculateTrunkDuctSizes: { projectID in
try await database.calculateTrunkDuctSizes(projectID: projectID).0 guard let details = try await database.projects.detail(projectID) else {
throw ProjectClientError.notFound(.project(projectID))
}
return try await database.calculateTrunkDuctSizes(details: details)
}, },
createProject: { userID, request in createProject: { userID, request in
let project = try await database.projects.create(userID, request) let project = try await database.projects.create(userID, request)
try await database.componentLoss.createDefaults(projectID: project.id) try await database.componentLosses.createDefaults(projectID: project.id)
return try await .init( return try await .init(
projectID: project.id, projectID: project.id,
rooms: database.rooms.fetch(project.id), rooms: database.rooms.fetch(project.id),
@@ -42,13 +45,10 @@ extension ProjectClient: DependencyKey {
request: database.makePdfRequest(projectID) request: database.makePdfRequest(projectID)
) )
let response = try await fileClient.streamFile( let response = try await fileClient.streamFile(at: pdfResponse.pdfPath) {
pdfResponse.pdfPath,
{
try await fileClient.removeFile(pdfResponse.htmlPath) try await fileClient.removeFile(pdfResponse.htmlPath)
try await fileClient.removeFile(pdfResponse.pdfPath) try await fileClient.removeFile(pdfResponse.pdfPath)
} }
)
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream") response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
response.headers.replaceOrAdd( response.headers.replaceOrAdd(
@@ -61,50 +61,3 @@ extension ProjectClient: DependencyKey {
} }
} }
extension DatabaseClient {
fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
@Dependency(\.manualD) var manualD
guard let projectDetails = try await projects.detail(projectID) else {
throw ProjectClientError("Project not found. id: \(projectID)")
}
let (ductSizes, shared) = try await calculateDuctSizes(details: projectDetails)
let frictionRateResponse = try await manualD.frictionRate(details: projectDetails)
guard let frictionRate = frictionRateResponse.frictionRate else {
throw ProjectClientError("Friction rate not found. id: \(projectID)")
}
return .init(
details: projectDetails,
ductSizes: ductSizes,
shared: shared,
frictionRate: frictionRate
)
}
}
extension PdfClient.Request {
init(
details: Project.Detail,
ductSizes: DuctSizes,
shared: DuctSizeSharedRequest,
frictionRate: FrictionRate
) {
self.init(
project: details.project,
rooms: details.rooms,
componentLosses: details.componentLosses,
ductSizes: ductSizes,
equipmentInfo: details.equipmentInfo,
maxSupplyTEL: shared.maxSupplyLength,
maxReturnTEL: shared.maxReturnLenght,
frictionRate: frictionRate,
projectSHR: shared.projectSHR
)
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
import ManualDCore
public struct ProjectClientError: Error {
public let reason: String
public init(_ reason: String) {
self.reason = reason
}
static func notFound(_ notFound: NotFound) -> Self {
.init(notFound.reason)
}
enum NotFound {
case project(Project.ID)
case frictionRate(Project.ID)
var reason: String {
switch self {
case .project(let id):
return "Project not found. id: \(id)"
case .frictionRate(let id):
return """
Friction unable to be calculated. id: \(id)
This usually means that not all the required steps have been completed.
Calculating the friction rate requires the component pressure losses to be set and
have a max equivalent length for both the supply and return.
"""
}
}
}
}

View File

@@ -1,4 +1,5 @@
import Elementary import Elementary
import Tagged
public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable { public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable {
@@ -25,4 +26,5 @@ extension Badge where Inner == Number {
public init(number: Double, digits: Int = 2) { public init(number: Double, digits: Int = 2) {
self.inner = Number(number, digits: digits) self.inner = Number(number, digits: digits)
} }
} }

View File

@@ -26,32 +26,6 @@ public struct SubmitButton: HTML, Sendable {
} }
} }
public struct CancelButton: HTML, Sendable {
let title: String
let type: HTMLAttribute<HTMLTag.button>.ButtonType
public init(
title: String = "Cancel",
type: HTMLAttribute<HTMLTag.button>.ButtonType = .button
) {
self.title = title
self.type = type
}
public var body: some HTML<HTMLTag.button> {
button(
.class(
"""
text-white font-bold text-xl bg-red-500 hover:bg-red-600 px-4 py-2 rounded-lg shadow-lg
"""
),
.type(type)
) {
title
}
}
}
public struct EditButton: HTML, Sendable { public struct EditButton: HTML, Sendable {
let title: String? let title: String?
let type: HTMLAttribute<HTMLTag.button>.ButtonType let type: HTMLAttribute<HTMLTag.button>.ButtonType

View File

@@ -9,13 +9,6 @@ extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
} }
} }
extension HTMLAttribute where Tag == HTMLTag.form {
public static func action(route: SiteRoute.View) -> Self {
action(SiteRoute.View.router.path(for: route))
}
}
extension HTMLAttribute where Tag == HTMLTag.input { extension HTMLAttribute where Tag == HTMLTag.input {
public static func value(_ string: String?) -> Self { public static func value(_ string: String?) -> Self {
@@ -42,9 +35,6 @@ extension HTMLAttribute where Tag == HTMLTag.button {
} }
extension HTML where Tag: HTMLTrait.Attributes.Global { extension HTML where Tag: HTMLTrait.Attributes.Global {
public func badge() -> _AttributedElement<Self> {
attributes(.class("badge badge-lg badge-outline"))
}
public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> { public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> {
attributes(.class("hidden"), when: shouldHide) attributes(.class("hidden"), when: shouldHide)

View File

@@ -1,45 +0,0 @@
import Elementary
// TODO: Remove, using svg's.
public struct Icon: HTML, Sendable {
let icon: String
public init(icon: String) {
self.icon = icon
}
public var body: some HTML {
i(.data("lucide", value: icon)) {}
}
}
extension Icon {
public init(_ icon: Key) {
self.init(icon: icon.icon)
}
public enum Key: String {
case circlePlus
case close
case doorClosed
case mapPin
case rulerDimensionLine
case squareFunction
case wind
var icon: String {
switch self {
case .circlePlus: return "circle-plus"
case .close: return "x"
case .doorClosed: return "door-closed"
case .mapPin: return "map-pin"
case .rulerDimensionLine: return "ruler-dimension-line"
case .squareFunction: return "square-function"
case .wind: return rawValue
}
}
}
}

View File

@@ -21,60 +21,6 @@ public struct LabeledInput: HTML, Sendable {
} }
} }
public struct Input: HTML, Sendable {
let id: String?
let name: String?
let placeholder: String
private var _name: String {
guard let name else {
return id ?? ""
}
return name
}
init(
id: String? = nil,
name: String? = nil,
placeholder: String
) {
self.id = id
self.name = name
self.placeholder = placeholder
}
public init(
id: String,
name: String? = nil,
placeholder: String
) {
self.id = id
self.name = name
self.placeholder = placeholder
}
public init(
name: String,
placeholder: String
) {
self.init(id: nil, name: name, placeholder: placeholder)
}
public var body: some HTML<HTMLTag.input> {
input(
.id(id ?? ""), .name(_name), .placeholder(placeholder),
.class(
"""
input w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1
-outline-offset-1 outline-slate-300 focus:outline focus:-outline-offset-2
focus:outline-indigo-600 invalid:border-red-500 out-of-range:border-red-500
"""
)
)
}
}
extension HTMLAttribute where Tag == HTMLTag.input { extension HTMLAttribute where Tag == HTMLTag.input {
public static func max(_ value: String) -> Self { public static func max(_ value: String) -> Self {

View File

@@ -6,15 +6,6 @@ public struct Number: HTML, Sendable {
let fractionDigits: Int let fractionDigits: Int
let value: Double let value: Double
// private var formatter: NumberFormatter {
// let formatter = NumberFormatter()
// formatter.maximumFractionDigits = fractionDigits
// formatter.numberStyle = .decimal
// formatter.groupingSize = 3
// formatter.groupingSeparator = ","
// return formatter
// }
public init( public init(
_ value: Double, _ value: Double,
digits fractionDigits: Int = 2 digits fractionDigits: Int = 2

View File

@@ -12,8 +12,8 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
} }
} }
var groups: [EffectiveLength.Group] { var groups: [EquivalentLength.FittingGroup] {
var groups = [EffectiveLength.Group]() var groups = [EquivalentLength.FittingGroup]()
for (n, group) in groupGroups.enumerated() { for (n, group) in groupGroups.enumerated() {
groups.append( groups.append(
.init( .init(
@@ -29,7 +29,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
} }
} }
extension EffectiveLength.Create { extension EquivalentLength.Create {
init( init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree, form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
@@ -45,7 +45,7 @@ extension EffectiveLength.Create {
} }
} }
extension EffectiveLength.Update { extension EquivalentLength.Update {
init( init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree, form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
projectID: Project.ID projectID: Project.ID

View File

@@ -54,14 +54,14 @@ extension ViewController: DependencyKey {
extension ViewController.Request { extension ViewController.Request {
func currentUser() throws -> User { func currentUser() throws -> User {
@Dependency(\.authClient.currentUser) var currentUser @Dependency(\.auth.currentUser) var currentUser
return try currentUser() return try currentUser()
} }
func authenticate( func authenticate(
_ login: User.Login _ login: User.Login
) async throws -> User { ) async throws -> User {
@Dependency(\.authClient) var auth @Dependency(\.auth) var auth
return try await auth.login(login) return try await auth.login(login)
} }
@@ -69,7 +69,7 @@ extension ViewController.Request {
func createAndAuthenticate( func createAndAuthenticate(
_ signup: User.Create _ signup: User.Create
) async throws -> User { ) async throws -> User {
@Dependency(\.authClient) var auth @Dependency(\.auth) var auth
return try await auth.createAndLogin(signup) return try await auth.createAndLogin(signup)
} }
} }

View File

@@ -70,7 +70,7 @@ extension ViewController.Request {
case .submitProfile(let profile): case .submitProfile(let profile):
return await view { return await view {
await ResultView { await ResultView {
_ = try await database.userProfile.create(profile) _ = try await database.userProfiles.create(profile)
let userID = profile.userID let userID = profile.userID
// let user = try currentUser() // let user = try currentUser()
return ( return (
@@ -108,7 +108,7 @@ extension ViewController.Request {
get async { get async {
@Dependency(\.database) var database @Dependency(\.database) var database
guard let user = try? currentUser() else { return nil } guard let user = try? currentUser() else { return nil }
return try? await database.userProfile.fetch(user.id)?.theme return try? await database.userProfiles.fetch(user.id)?.theme
} }
} }
@@ -366,8 +366,8 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
return await request.view { return await request.view {
await ResultView { await ResultView {
let equipment = try await database.equipment.fetch(projectID) let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID) let componentLosses = try await database.componentLosses.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID) let lengths = try await database.equivalentLengths.fetchMax(projectID)
return ( return (
try await database.projects.getCompletedSteps(projectID), try await database.projects.getCompletedSteps(projectID),
@@ -407,16 +407,16 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
return EmptyHTML() return EmptyHTML()
case .delete(let id): case .delete(let id):
return await view(on: request, projectID: projectID) { return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.delete(id) _ = try await database.componentLosses.delete(id)
} }
case .submit(let form): case .submit(let form):
return await view(on: request, projectID: projectID) { return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.create(form) _ = try await database.componentLosses.create(form)
} }
case .update(let id, let form): case .update(let id, let form):
return await view(on: request, projectID: projectID) { return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.update(id, form) _ = try await database.componentLosses.update(id, form)
} }
} }
} }
@@ -464,7 +464,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .delete(let id): case .delete(let id):
return await ResultView { return await ResultView {
try await database.effectiveLength.delete(id) try await database.equivalentLengths.delete(id)
} }
case .index: case .index:
@@ -481,16 +481,16 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .update(let id, let form): case .update(let id, let form):
return await view(on: request, projectID: projectID) { return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.update(id, .init(form: form, projectID: projectID)) _ = try await database.equivalentLengths.update(id, .init(form: form, projectID: projectID))
} }
case .submit(let step): case .submit(let step):
switch step { switch step {
case .one(let stepOne): case .one(let stepOne):
return await ResultView { return await ResultView {
var effectiveLength: EffectiveLength? = nil var effectiveLength: EquivalentLength? = nil
if let id = stepOne.id { if let id = stepOne.id {
effectiveLength = try await database.effectiveLength.get(id) effectiveLength = try await database.equivalentLengths.get(id)
} }
return effectiveLength return effectiveLength
} onSuccess: { effectiveLength in } onSuccess: { effectiveLength in
@@ -503,9 +503,9 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .two(let stepTwo): case .two(let stepTwo):
return await ResultView { return await ResultView {
request.logger.debug("ViewController: Got step two...") request.logger.debug("ViewController: Got step two...")
var effectiveLength: EffectiveLength? = nil var effectiveLength: EquivalentLength? = nil
if let id = stepTwo.id { if let id = stepTwo.id {
effectiveLength = try await database.effectiveLength.get(id) effectiveLength = try await database.equivalentLengths.get(id)
} }
return effectiveLength return effectiveLength
} onSuccess: { effectiveLength in } onSuccess: { effectiveLength in
@@ -515,7 +515,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
} }
case .three(let stepThree): case .three(let stepThree):
return await view(on: request, projectID: projectID) { return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.create( _ = try await database.equivalentLengths.create(
.init(form: stepThree, projectID: projectID) .init(form: stepThree, projectID: projectID)
) )
} }
@@ -536,7 +536,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
try await catching() try await catching()
return ( return (
try await database.projects.getCompletedSteps(projectID), try await database.projects.getCompletedSteps(projectID),
try await database.effectiveLength.fetch(projectID) try await database.equivalentLengths.fetch(projectID)
) )
} onSuccess: { (steps, equivalentLengths) in } onSuccess: { (steps, equivalentLengths) in
ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) { ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) {
@@ -652,11 +652,11 @@ extension SiteRoute.View.UserRoute.Profile {
return await view(on: request) return await view(on: request)
case .submit(let form): case .submit(let form):
return await view(on: request) { return await view(on: request) {
_ = try await database.userProfile.create(form) _ = try await database.userProfiles.create(form)
} }
case .update(let id, let updates): case .update(let id, let updates):
return await view(on: request) { return await view(on: request) {
_ = try await database.userProfile.update(id, updates) _ = try await database.userProfiles.update(id, updates)
} }
} }
} }
@@ -673,7 +673,7 @@ extension SiteRoute.View.UserRoute.Profile {
let user = try request.currentUser() let user = try request.currentUser()
return ( return (
user, user,
try await database.userProfile.fetch(user.id) try await database.userProfiles.fetch(user.id)
) )
} onSuccess: { (user, profile) in } onSuccess: { (user, profile) in
UserView(user: user, profile: profile) UserView(user: user, profile: profile)

View File

@@ -7,7 +7,7 @@ import Styleguide
struct EffectiveLengthForm: HTML, Sendable { struct EffectiveLengthForm: HTML, Sendable {
static func id(_ equivalentLength: EffectiveLength?) -> String { static func id(_ equivalentLength: EquivalentLength?) -> String {
let base = "equivalentLengthForm" let base = "equivalentLengthForm"
guard let equivalentLength else { return base } guard let equivalentLength else { return base }
return "\(base)_\(equivalentLength.id.uuidString.replacing("-", with: ""))" return "\(base)_\(equivalentLength.id.uuidString.replacing("-", with: ""))"
@@ -15,15 +15,15 @@ struct EffectiveLengthForm: HTML, Sendable {
let projectID: Project.ID let projectID: Project.ID
let dismiss: Bool let dismiss: Bool
let type: EffectiveLength.EffectiveLengthType let type: EquivalentLength.EffectiveLengthType
let effectiveLength: EffectiveLength? let effectiveLength: EquivalentLength?
var id: String { Self.id(effectiveLength) } var id: String { Self.id(effectiveLength) }
init( init(
projectID: Project.ID, projectID: Project.ID,
dismiss: Bool, dismiss: Bool,
type: EffectiveLength.EffectiveLengthType = .supply type: EquivalentLength.EffectiveLengthType = .supply
) { ) {
self.projectID = projectID self.projectID = projectID
self.dismiss = dismiss self.dismiss = dismiss
@@ -32,7 +32,7 @@ struct EffectiveLengthForm: HTML, Sendable {
} }
init( init(
effectiveLength: EffectiveLength effectiveLength: EquivalentLength
) { ) {
self.dismiss = true self.dismiss = true
self.type = effectiveLength.type self.type = effectiveLength.type
@@ -55,7 +55,7 @@ struct EffectiveLengthForm: HTML, Sendable {
struct StepOne: HTML, Sendable { struct StepOne: HTML, Sendable {
let projectID: Project.ID let projectID: Project.ID
let effectiveLength: EffectiveLength? let effectiveLength: EquivalentLength?
var route: String { var route: String {
let baseRoute = SiteRoute.View.router.path( let baseRoute = SiteRoute.View.router.path(
@@ -97,7 +97,7 @@ struct EffectiveLengthForm: HTML, Sendable {
struct StepTwo: HTML, Sendable { struct StepTwo: HTML, Sendable {
let projectID: Project.ID let projectID: Project.ID
let stepOne: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepOne let stepOne: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepOne
let effectiveLength: EffectiveLength? let effectiveLength: EquivalentLength?
var route: String { var route: String {
let baseRoute = SiteRoute.View.router.path( let baseRoute = SiteRoute.View.router.path(
@@ -152,7 +152,7 @@ struct EffectiveLengthForm: HTML, Sendable {
struct StepThree: HTML, Sendable { struct StepThree: HTML, Sendable {
let projectID: Project.ID let projectID: Project.ID
let effectiveLength: EffectiveLength? let effectiveLength: EquivalentLength?
let stepTwo: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo let stepTwo: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo
var route: String { var route: String {
@@ -254,10 +254,10 @@ struct StraightLengthField: HTML, Sendable {
struct GroupField: HTML, Sendable { struct GroupField: HTML, Sendable {
let style: EffectiveLength.EffectiveLengthType let style: EquivalentLength.EffectiveLengthType
let group: EffectiveLength.Group? let group: EquivalentLength.FittingGroup?
init(style: EffectiveLength.EffectiveLengthType, group: EffectiveLength.Group? = nil) { init(style: EquivalentLength.EffectiveLengthType, group: EquivalentLength.FittingGroup? = nil) {
self.style = style self.style = style
self.group = group self.group = group
} }
@@ -307,7 +307,7 @@ struct GroupField: HTML, Sendable {
struct GroupSelect: HTML, Sendable { struct GroupSelect: HTML, Sendable {
let style: EffectiveLength.EffectiveLengthType let style: EquivalentLength.EffectiveLengthType
var body: some HTML { var body: some HTML {
label(.class("select")) { label(.class("select")) {
@@ -328,13 +328,13 @@ struct GroupSelect: HTML, Sendable {
struct GroupTypeSelect: HTML, Sendable { struct GroupTypeSelect: HTML, Sendable {
let projectID: Project.ID let projectID: Project.ID
let selected: EffectiveLength.EffectiveLengthType let selected: EquivalentLength.EffectiveLengthType
var body: some HTML<HTMLTag.label> { var body: some HTML<HTMLTag.label> {
label(.class("select w-full")) { label(.class("select w-full")) {
span(.class("label")) { "Type" } span(.class("label")) { "Type" }
select(.name("type"), .id("type")) { select(.name("type"), .id("type")) {
for value in EffectiveLength.EffectiveLengthType.allCases { for value in EquivalentLength.EffectiveLengthType.allCases {
option( option(
.value("\(value.rawValue)"), .value("\(value.rawValue)"),
) { value.title } ) { value.title }
@@ -345,7 +345,7 @@ struct GroupTypeSelect: HTML, Sendable {
} }
} }
extension EffectiveLength.EffectiveLengthType { extension EquivalentLength.EffectiveLengthType {
var title: String { rawValue.capitalized } var title: String { rawValue.capitalized }

View File

@@ -5,9 +5,9 @@ import Styleguide
struct EffectiveLengthsTable: HTML, Sendable { struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [EffectiveLength] let effectiveLengths: [EquivalentLength]
private var sortedLengths: [EffectiveLength] { private var sortedLengths: [EquivalentLength] {
effectiveLengths.sorted { effectiveLengths.sorted {
$0.totalEquivalentLength > $1.totalEquivalentLength $0.totalEquivalentLength > $1.totalEquivalentLength
} }
@@ -55,7 +55,7 @@ struct EffectiveLengthsTable: HTML, Sendable {
struct EffectiveLenghtRow: HTML, Sendable { struct EffectiveLenghtRow: HTML, Sendable {
let effectiveLength: EffectiveLength let effectiveLength: EquivalentLength
private var deleteRoute: SiteRoute.View { private var deleteRoute: SiteRoute.View {
.project( .project(

View File

@@ -7,14 +7,14 @@ struct EffectiveLengthsView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID @Environment(ProjectViewValue.$projectID) var projectID
let effectiveLengths: [EffectiveLength] let effectiveLengths: [EquivalentLength]
var supplies: [EffectiveLength] { var supplies: [EquivalentLength] {
effectiveLengths.filter({ $0.type == .supply }) effectiveLengths.filter({ $0.type == .supply })
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength } .sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
} }
var returns: [EffectiveLength] { var returns: [EquivalentLength] {
effectiveLengths.filter({ $0.type == .return }) effectiveLengths.filter({ $0.type == .return })
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength } .sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
} }

View File

@@ -8,7 +8,7 @@ struct FrictionRateView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID @Environment(ProjectViewValue.$projectID) var projectID
let componentLosses: [ComponentPressureLoss] let componentLosses: [ComponentPressureLoss]
let equivalentLengths: EffectiveLength.MaxContainer let equivalentLengths: EquivalentLength.MaxContainer
let frictionRate: FrictionRate? let frictionRate: FrictionRate?
private var availableStaticPressure: Double? { private var availableStaticPressure: Double? {

View File

@@ -233,19 +233,19 @@ extension ManualDClient {
func frictionRate( func frictionRate(
equipmentInfo: EquipmentInfo?, equipmentInfo: EquipmentInfo?,
componentLosses: [ComponentPressureLoss], componentLosses: [ComponentPressureLoss],
effectiveLength: EffectiveLength.MaxContainer effectiveLength: EquivalentLength.MaxContainer
) async throws -> FrictionRate? { ) async throws -> FrictionRate? {
guard let staticPressure = equipmentInfo?.staticPressure else { guard let staticPressure = equipmentInfo?.staticPressure else {
return nil return nil
} }
guard let totalEquivalentLength = effectiveLength.total else { guard let totalEquivalentLength = effectiveLength.totalEquivalentLength else {
return nil return nil
} }
return try await self.frictionRate( return try await self.frictionRate(
.init( .init(
externalStaticPressure: staticPressure, externalStaticPressure: staticPressure,
componentPressureLosses: componentLosses, componentPressureLosses: componentLosses,
totalEffectiveLength: Int(totalEquivalentLength) totalEquivalentLength: Int(totalEquivalentLength)
) )
) )
} }

13
TODO.md
View File

@@ -1,9 +1,18 @@
# TODO's # TODO's
- [x] Fix theme not working when selected upon signup. - [x] Fix theme not working when selected upon signup.
- [ ] Pdf generation - [x] Pdf generation
- [ ] Add postgres / mysql support - [x] Add postgres / mysql support
- [ ] Opensource / license ?? - [ ] Opensource / license ??
- [ ] Figure out domain to host (currently thinking ductcalc.pro) - [ ] Figure out domain to host (currently thinking ductcalc.pro)
- [ ] Logo / navbar name may have to change if it's not duct-calc. - [ ] Logo / navbar name may have to change if it's not duct-calc.
- [ ] MainPage meta items will have to change also - [ ] MainPage meta items will have to change also
- [ ] Add ability for either sensible or total load while specifying a room load.
- CoolCalc current version specifies the sensible cooling for a room break down,
and currently we require the total load and calculate sensible based on project
shr.
- [ ] Add ability to associate room load / airflow with another room.
- [ ] Trunk size form, room / register selection is wonky when labels are long.
- They will overlap each other making it difficult to read / decipher which checkbox belongs
to which label.
- [ ] Add select all rooms for trunks, useful for sizing main supply or return trunks.

View File

@@ -1,104 +0,0 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
import URLRouting
@Suite("ProjectRouteTests")
struct ProjectRouteTests {
let router = SiteRoute.Api.router
@Test
func create() throws {
let json = """
{
\"name\": \"Test\",
\"streetAddress\": \"1234 Seasme Street\",
\"city\": \"Nowhere\",
\"state\": \"OH\",
\"zipCode\": \"55555\"
}
"""
var request = URLRequestData(
method: "POST",
path: "/api/v1/projects",
body: .init(json.utf8)
)
let route = try router.parse(&request)
#expect(
route
== .project(
.create(
.init(
name: "Test",
streetAddress: "1234 Seasme Street",
city: "Nowhere",
state: "OH",
zipCode: "55555"
)
)
)
)
}
@Test
func delete() throws {
let id = UUID(0)
var request = URLRequestData(
method: "DELETE",
path: "/api/v1/projects/\(id)"
)
let route = try router.parse(&request)
#expect(route == .project(.delete(id: id)))
}
@Test
func get() throws {
let id = UUID(0)
var request = URLRequestData(
method: "GET",
path: "/api/v1/projects/\(id)"
)
let route = try router.parse(&request)
#expect(route == .project(.get(id: id)))
}
@Test
func index() throws {
var request = URLRequestData(
method: "GET",
path: "/api/v1/projects"
)
let route = try router.parse(&request)
#expect(route == .project(.index))
}
@Test
func formData() throws {
let p = Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
}
}
}
.map(.memberwise(SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo.init))
}
var request = URLRequestData(
body: .init(
"name=Test&type=supply&straightLengths=20&straightLengths=10"
.utf8
)
)
let value = try p.parse(&request)
print(value)
#expect(value.straightLengths == [20, 10])
}
}

View File

@@ -0,0 +1,68 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
@testable import DatabaseClient
@Suite
struct ComponentLossTests {
@Test
func happyPaths() async throws {
try await withTestUserAndProject { user, project in
@Dependency(\.database) var database
// let project = try await database.projects.create(user.id, .mock)
let componentLoss = try await database.componentLosses.create(
.init(projectID: project.id, name: "Test", value: 0.2)
)
let fetched = try await database.componentLosses.fetch(project.id)
#expect(fetched == [componentLoss])
let got = try await database.componentLosses.get(componentLoss.id)
#expect(got == componentLoss)
let updated = try await database.componentLosses.update(
componentLoss.id, .init(name: "Updated", value: nil)
)
#expect(updated.id == componentLoss.id)
#expect(updated.value == componentLoss.value)
#expect(updated.name == "Updated")
try await database.componentLosses.delete(componentLoss.id)
}
}
@Test
func notFound() async throws {
try await withDatabase {
@Dependency(\.database.componentLosses) var componentLosses
await #expect(throws: NotFoundError.self) {
try await componentLosses.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await componentLosses.update(UUID(0), .init(name: "Updated"))
}
}
}
@Test(
arguments: [
ComponentLossModel(name: "", value: 0.2, projectID: UUID(0)),
ComponentLossModel(name: "Foo", value: -0.2, projectID: UUID(0)),
ComponentLossModel(name: "Foo", value: 1.2, projectID: UUID(0)),
ComponentLossModel(name: "", value: -0.2, projectID: UUID(0)),
]
)
func validations(model: ComponentLossModel) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
}

View File

@@ -0,0 +1,69 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
@testable import DatabaseClient
@Suite
struct EquipmentTests {
@Test
func happyPath() async throws {
try await withTestUserAndProject { user, project in
@Dependency(\.database) var database
let equipment = try await database.equipment.create(
.init(projectID: project.id, heatingCFM: 1000, coolingCFM: 1000)
)
let fetched = try await database.equipment.fetch(project.id)
#expect(fetched == equipment)
let got = try await database.equipment.get(equipment.id)
#expect(got == equipment)
let updated = try await database.equipment.update(
equipment.id, .init(heatingCFM: 900)
)
#expect(updated.heatingCFM == 900)
#expect(updated.id == equipment.id)
try await database.equipment.delete(equipment.id)
}
}
@Test
func notFound() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database.equipment) var equipment
let fetched = try await equipment.fetch(project.id)
#expect(fetched == nil)
await #expect(throws: NotFoundError.self) {
try await equipment.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await equipment.update(UUID(0), .init(staticPressure: 0.3))
}
}
}
@Test(
arguments: [
EquipmentModel(staticPressure: -1, heatingCFM: 1000, coolingCFM: 1000, projectID: UUID(0)),
EquipmentModel(staticPressure: 0.5, heatingCFM: -1, coolingCFM: 1000, projectID: UUID(0)),
EquipmentModel(staticPressure: 0.5, heatingCFM: 1000, coolingCFM: -1000, projectID: UUID(0)),
EquipmentModel(staticPressure: 1.1, heatingCFM: 1000, coolingCFM: -1000, projectID: UUID(0)),
]
)
func validations(model: EquipmentModel) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
}

View File

@@ -0,0 +1,123 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
@testable import DatabaseClient
@Suite
struct EquivalentLengthTests {
@Test
func happyPath() async throws {
try await withTestUserAndProject { user, project in
@Dependency(\.database.equivalentLengths) var equivalentLengths
let equivalentLength = try await equivalentLengths.create(
.init(
projectID: project.id,
name: "Test",
type: .supply,
straightLengths: [10],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "a", value: 30, quantity: 2),
]
)
)
let fetched = try await equivalentLengths.fetch(project.id)
#expect(fetched == [equivalentLength])
let got = try await equivalentLengths.get(equivalentLength.id)
#expect(got == equivalentLength)
var max = try await equivalentLengths.fetchMax(project.id)
#expect(max.supply == equivalentLength)
#expect(max.return == nil)
let returnLength = try await equivalentLengths.create(
.init(
projectID: project.id,
name: "Test",
type: .return,
straightLengths: [10],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "a", value: 30, quantity: 2),
]
)
)
max = try await equivalentLengths.fetchMax(project.id)
#expect(max.supply == equivalentLength)
#expect(max.return == returnLength)
let updated = try await equivalentLengths.update(
equivalentLength.id, .init(name: "Supply Test")
)
#expect(updated.name == "Supply Test")
#expect(updated.id == equivalentLength.id)
try await equivalentLengths.delete(equivalentLength.id)
}
}
@Test
func notFound() async throws {
try await withDatabase {
@Dependency(\.database.equivalentLengths) var equivalentLengths
await #expect(throws: NotFoundError.self) {
try await equivalentLengths.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await equivalentLengths.update(UUID(0), .init())
}
}
}
@Test(
arguments: [
EquivalentLength.Create(
projectID: UUID(0), name: "", type: .return, straightLengths: [], groups: []
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [-1, 1], groups: []
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, -1], groups: []
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
groups: [
.init(group: -1, letter: "a", value: 1.0, quantity: 1)
]
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
groups: [
.init(group: 1, letter: "1", value: 1.0, quantity: 1)
]
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
groups: [
.init(group: 1, letter: "a", value: -1.0, quantity: 1)
]
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
groups: [
.init(group: 1, letter: "a", value: 1.0, quantity: -1)
]
),
]
)
func validations(model: EquivalentLength.Create) {
#expect(throws: (any Error).self) {
try model.toModel().validate()
}
}
}

View File

@@ -0,0 +1,83 @@
import App
import DatabaseClient
import Dependencies
import Fluent
import FluentSQLiteDriver
import Foundation
import ManualDCore
import NIO
import Vapor
// Helper to create an in-memory database used for testing.
func withDatabase(
setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: () async throws -> Void
) async throws {
let app = try await Application.make(.testing)
do {
try await configure(app, in: .live())
let database = app.db
try await app.autoMigrate()
try await withDependencies {
$0.uuid = .incrementing
$0.date = .init { Date() }
$0.database = .live(database: database)
setupDependencies(&$0)
} operation: {
try await operation()
}
try await app.autoRevert()
try await app.asyncShutdown()
} catch {
try? await app.autoRevert()
try await app.asyncShutdown()
throw error
}
}
/// Set's up the database and a test user for running tests that require a
/// a user.
func withTestUser(
setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: (User) async throws -> Void
) async throws {
try await withDatabase(setupDependencies: setupDependencies) {
@Dependency(\.database.users) var users
let user = try await users.create(
.init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret")
)
try await operation(user)
}
}
/// Set's up the database and a test user for running tests that require a
/// a user.
func withTestUserAndProject(
setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: (User, Project) async throws -> Void
) async throws {
try await withDatabase(setupDependencies: setupDependencies) {
@Dependency(\.database) var database
let user = try await database.users.create(
.init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret")
)
let project = try await database.projects.create(user.id, .mock)
try await operation(user, project)
}
}
extension Project.Create {
static let mock = Self(
name: "Testy McTestface",
streetAddress: "1234 Sesame St",
city: "Nowhere",
state: "MN",
zipCode: "55555",
sensibleHeatRatio: 0.83
)
}

View File

@@ -0,0 +1,204 @@
import Dependencies
import Fluent
import FluentSQLiteDriver
import ManualDCore
import Testing
import Vapor
@testable import DatabaseClient
@Suite
struct ProjectTests {
@Test
func projectHappyPaths() async throws {
try await withTestUser { user in
@Dependency(\.database.projects) var projects
let project = try await projects.create(user.id, .mock)
let got = try await projects.get(project.id)
#expect(got == project)
let page = try await projects.fetch(user.id, .init(page: 1, per: 25))
#expect(page.items.first! == project)
let updated = try await projects.update(project.id, .init(sensibleHeatRatio: 0.83))
#expect(updated.sensibleHeatRatio == 0.83)
#expect(updated.id == project.id)
let shr = try await projects.getSensibleHeatRatio(project.id)
#expect(shr == 0.83)
try await projects.delete(project.id)
}
}
@Test
func notFound() async throws {
try await withDatabase {
@Dependency(\.database.projects) var projects
await #expect(throws: NotFoundError.self) {
try await projects.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await projects.update(UUID(0), .init(name: "Foo"))
}
await #expect(throws: NotFoundError.self) {
try await projects.getSensibleHeatRatio(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await projects.getCompletedSteps(UUID(0))
}
}
}
@Test
func completedSteps() async throws {
try await withTestUser { user in
@Dependency(\.database) var database
let project = try await database.projects.create(user.id, .mock)
var completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.equipmentInfo == false)
#expect(completed.equivalentLength == false)
#expect(completed.frictionRate == false)
#expect(completed.rooms == false)
_ = try await database.equipment.create(
.init(projectID: project.id, heatingCFM: 1000, coolingCFM: 1000)
)
completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.equipmentInfo == true)
_ = try await database.componentLosses.create(
.init(projectID: project.id, name: "Test", value: 0.2)
)
completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.frictionRate == true)
_ = try await database.rooms.create(
.init(projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345)
)
completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.rooms == true)
_ = try await database.equivalentLengths.create(
.init(
projectID: project.id, name: "Supply", type: .supply, straightLengths: [1], groups: [])
)
completed = try await database.projects.getCompletedSteps(project.id)
// Should not be complete until we have both return and supply for a project.
#expect(completed.equivalentLength == false)
_ = try await database.equivalentLengths.create(
.init(
projectID: project.id, name: "Return", type: .return, straightLengths: [1], groups: [])
)
completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.equipmentInfo == true)
#expect(completed.equivalentLength == true)
#expect(completed.frictionRate == true)
#expect(completed.rooms == true)
}
}
@Test
func detail() async throws {
try await withTestUser { user in
@Dependency(\.database) var database
let project = try await database.projects.create(user.id, .mock)
var detail = try await database.projects.detail(project.id)
#expect(detail == nil)
let equipment = try await database.equipment.create(
.init(projectID: project.id, heatingCFM: 1000, coolingCFM: 1000)
)
detail = try await database.projects.detail(project.id)
#expect(detail != nil)
let componentLoss = try await database.componentLosses.create(
.init(projectID: project.id, name: "Test", value: 0.2)
)
let room = try await database.rooms.create(
.init(projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345)
)
let supplyLength = try await database.equivalentLengths.create(
.init(
projectID: project.id, name: "Supply", type: .supply, straightLengths: [1], groups: [])
)
let returnLength = try await database.equivalentLengths.create(
.init(
projectID: project.id, name: "Return", type: .return, straightLengths: [1], groups: [])
)
detail = try await database.projects.detail(project.id)
#expect(detail?.componentLosses == [componentLoss])
#expect(detail?.equipmentInfo == equipment)
#expect(detail?.rooms == [room])
#expect(detail?.equivalentLengths.contains(supplyLength) == true)
#expect(detail?.equivalentLengths.contains(returnLength) == true)
}
}
@Test(
arguments: [
ProjectModel(
name: "", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH", zipCode: "55555",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "", city: "Nowhere", state: "OH", zipCode: "55555",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "", state: "OH", zipCode: "55555",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "",
zipCode: "55555",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH",
zipCode: "",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH",
zipCode: "55555",
sensibleHeatRatio: -1, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH",
zipCode: "55555",
sensibleHeatRatio: 1.1, userID: UUID(0)
),
]
)
func validations(model: ProjectModel) {
var errors = [String]()
#expect(throws: (any Error).self) {
do {
try model.validate()
} catch {
// Just checking to make sure I'm not testing the same error over and over /
// making sure I've reset to good values / only testing one property at a time.
#expect(!errors.contains("\(error)"))
errors.append("\(error)")
throw error
}
}
}
}

View File

@@ -0,0 +1,147 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
import Validations
@testable import DatabaseClient
@Suite
struct RoomTests {
@Test
func happyPath() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database.rooms) var rooms
let room = try await rooms.create(
.init(projectID: project.id, name: "Test", heatingLoad: 1234, coolingTotal: 1234)
)
let fetched = try await rooms.fetch(project.id)
#expect(fetched == [room])
let got = try await rooms.get(room.id)
#expect(got == room)
let updated = try await rooms.update(
room.id,
.init(rectangularSizes: [.init(id: UUID(0), register: 1, height: 8)])
)
#expect(updated.id == room.id)
let updatedSize = try await rooms.updateRectangularSize(
room.id, .init(id: UUID(0), register: 1, height: 10)
)
#expect(updatedSize.id == room.id)
let deletedSize = try await rooms.deleteRectangularSize(room.id, UUID(0))
#expect(deletedSize.rectangularSizes == nil)
try await rooms.delete(room.id)
}
}
@Test
func createMany() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database.rooms) var rooms
let created = try await rooms.createMany([
.init(projectID: project.id, name: "Test 1", heatingLoad: 1234, coolingTotal: 1234),
.init(projectID: project.id, name: "Test 2", heatingLoad: 1234, coolingTotal: 1234),
])
#expect(created.count == 2)
#expect(created[0].name == "Test 1")
#expect(created[1].name == "Test 2")
}
}
@Test
func notFound() async throws {
try await withDatabase {
@Dependency(\.database.rooms) var rooms
await #expect(throws: NotFoundError.self) {
try await rooms.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await rooms.deleteRectangularSize(UUID(0), UUID(1))
}
await #expect(throws: NotFoundError.self) {
try await rooms.update(UUID(0), .init())
}
await #expect(throws: NotFoundError.self) {
try await rooms.updateRectangularSize(UUID(0), .init(height: 8))
}
}
}
@Test(
arguments: [
Room.Create(
projectID: UUID(0),
name: "",
heatingLoad: 12345,
coolingTotal: 12344,
coolingSensible: nil,
registerCount: 1
),
Room.Create(
projectID: UUID(0),
name: "Test",
heatingLoad: -12345,
coolingTotal: 12344,
coolingSensible: nil,
registerCount: 1
),
Room.Create(
projectID: UUID(0),
name: "Test",
heatingLoad: 12345,
coolingTotal: -12344,
coolingSensible: nil,
registerCount: 1
),
Room.Create(
projectID: UUID(0),
name: "Test",
heatingLoad: 12345,
coolingTotal: 12344,
coolingSensible: -123,
registerCount: 1
),
Room.Create(
projectID: UUID(0),
name: "Test",
heatingLoad: 12345,
coolingTotal: 12344,
coolingSensible: nil,
registerCount: -1
),
Room.Create(
projectID: UUID(0),
name: "",
heatingLoad: -12345,
coolingTotal: -12344,
coolingSensible: -1,
registerCount: -1
),
]
)
func validations(room: Room.Create) throws {
#expect(throws: (any Error).self) {
// do {
try room.toModel().validate()
// } catch {
// print("\(error)")
// throw error
// }
}
}
}

View File

@@ -0,0 +1,93 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
@testable import DatabaseClient
@Suite
struct TrunkSizeTests {
@Test
func happyPath() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database) var database
let room = try await database.rooms.create(
.init(
projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345,
coolingSensible: nil, registerCount: 5)
)
let trunk = try await database.trunkSizes.create(
.init(
projectID: project.id,
type: .supply,
rooms: [room.id: [1, 2, 3]],
height: 8,
name: "Test Trunk"
)
)
let fetched = try await database.trunkSizes.fetch(project.id)
#expect(fetched == [trunk])
let got = try await database.trunkSizes.get(trunk.id)
#expect(got == trunk)
let updated = try await database.trunkSizes.update(
trunk.id, .init(type: .return)
)
#expect(updated.type == .return)
#expect(updated.id == trunk.id)
try await database.trunkSizes.delete(trunk.id)
}
}
@Test
func notFound() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database.trunkSizes) var trunks
await #expect(throws: NotFoundError.self) {
try await trunks.create(
.init(projectID: project.id, type: .supply, rooms: [UUID(0): [1]])
)
}
await #expect(throws: NotFoundError.self) {
try await trunks.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await trunks.update(UUID(0), .init(type: .return))
}
}
}
@Test(
arguments: [
TrunkModel(projectID: UUID(0), type: .return, height: 8, name: ""),
TrunkModel(projectID: UUID(0), type: .return, height: -8, name: "Test"),
]
)
func validations(model: TrunkModel) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
@Test(
arguments: [
TrunkRoomModel(trunkID: UUID(0), roomID: UUID(0), registers: [-1, 1], type: .return),
TrunkRoomModel(trunkID: UUID(0), roomID: UUID(0), registers: [1, -1], type: .return),
TrunkRoomModel(trunkID: UUID(0), roomID: UUID(0), registers: [], type: .return),
]
)
func trunkRoomModelValidations(model: TrunkRoomModel) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
}

View File

@@ -0,0 +1,190 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
import Vapor
@testable import DatabaseClient
@Suite
struct UserDatabaseTests {
@Test
func happyPaths() async throws {
try await withDatabase {
@Dependency(\.database.users) var users
let user = try await users.create(
.init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret")
)
#expect(user.email == "testy@example.com")
// Test login the user in
let token = try await users.login(
.init(email: user.email, password: "super-secret")
)
#expect(token.userID == user.id)
// Test the same token is returned.
let token2 = try await users.login(
.init(email: user.email, password: "super-secret")
)
#expect(token.id == token2.id)
// Test logging out
try await users.logout(token.id)
try await users.delete(user.id)
let shouldBeNilUser = try await users.get(user.id)
#expect(shouldBeNilUser == nil)
}
}
@Test
func deleteFailsWithInvalidUserID() async throws {
try await withDatabase {
@Dependency(\.database.users) var users
await #expect(throws: NotFoundError.self) {
try await users.delete(UUID(0))
}
}
}
@Test
func logoutIgnoresUnfoundTokenID() async throws {
try await withDatabase {
@Dependency(\.database.users) var users
try await users.logout(UUID(0))
}
}
@Test
func loginFails() async throws {
try await withDatabase {
@Dependency(\.database.users) var users
await #expect(throws: NotFoundError.self) {
try await users.login(
.init(email: "foo@example.com", password: "super-secret")
)
}
let user = try await users.create(
.init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret")
)
// Ensure can not login with invalid password
await #expect(throws: Abort.self) {
try await users.login(
.init(email: user.email, password: "wrong-password")
)
}
}
}
@Test
func userProfileHappyPath() async throws {
try await withTestUser { user in
@Dependency(\.database.userProfiles) var profiles
let profile = try await profiles.create(
.init(
userID: user.id,
firstName: "Testy",
lastName: "McTestface",
companyName: "Acme Co.",
streetAddress: "12345 Sesame St",
city: "Nowhere",
state: "FL",
zipCode: "55555"
)
)
let fetched = try await profiles.fetch(user.id)
#expect(fetched == profile)
let got = try await profiles.get(profile.id)
#expect(got == profile)
let updated = try await profiles.update(profile.id, .init(firstName: "Updated"))
#expect(updated.firstName == "Updated")
#expect(updated.id == profile.id)
try await profiles.delete(profile.id)
}
}
@Test
func testUserProfileFails() async throws {
try await withDatabase {
@Dependency(\.database.userProfiles) var profiles
await #expect(throws: NotFoundError.self) {
try await profiles.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await profiles.update(UUID(0), .init(firstName: "Foo"))
}
}
}
@Test(
arguments: [
UserProfileModel(
userID: UUID(0), firstName: "", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "", city: "Nowhere", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: ""
),
]
)
func profileValidations(model: UserProfileModel) {
var errors = [String]()
#expect(throws: (any Error).self) {
do {
try model.validate()
} catch {
// Just checking to make sure I'm not testing the same error over and over /
// making sure I've reset to good values / only testing one property at a time.
#expect(!errors.contains("\(error)"))
errors.append("\(error)")
throw error
}
}
}
@Test(
arguments: [
User.Create(email: "", password: "super-secret", confirmPassword: "super-secret"),
User.Create(email: "testy@example.com", password: "", confirmPassword: "super-secret"),
User.Create(email: "testy@example.com", password: "super-secret", confirmPassword: ""),
User.Create(email: "testy@example.com", password: "super", confirmPassword: "super"),
]
)
func userValidations(model: User.Create) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
}

View File

@@ -24,62 +24,16 @@ struct ManualDClientTests {
@Test @Test
func ductSize() async throws { func ductSize() async throws {
let response = try await manualD.ductSize( let response = try await manualD.ductSize(88, 0.06)
.init(designCFM: 88, frictionRate: 0.06)
)
#expect(numberFormatter.string(for: response.calculatedSize) == "6.07") #expect(numberFormatter.string(for: response.calculatedSize) == "6.07")
#expect(response.finalSize == 7) #expect(response.finalSize == 7)
#expect(response.flexSize == 7) #expect(response.flexSize == 7)
#expect(response.velocity == 329) #expect(response.velocity == 329)
} }
// @Test
// func frictionRate() async throws {
// let response = try await manualD.frictionRate(
// .init(
// externalStaticPressure: 0.5,
// componentPressureLosses: .mock,
// totalEffectiveLength: 185
// )
// )
// #expect(numberFormatter.string(for: response.availableStaticPressure) == "0.11")
// #expect(numberFormatter.string(for: response.frictionRate) == "0.06")
// }
// @Test
// func frictionRateFails() async throws {
// await #expect(throws: ManualDError.self) {
// _ = try await manualD.frictionRate(
// .init(
// externalStaticPressure: 0.5,
// componentPressureLosses: .mock,
// totalEffectiveLength: 0
// )
// )
// }
// }
@Test
func totalEffectiveLength() async throws {
let response = try await manualD.totalEquivalentLength(
.init(
trunkLengths: [25],
runoutLengths: [10],
effectiveLengthGroups: [
// NOTE: These are made up and may not correspond to actual manual-d group tel's.
EffectiveLengthGroup(group: 1, letter: "a", effectiveLength: 20, category: .supply),
EffectiveLengthGroup(group: 2, letter: "a", effectiveLength: 30, category: .supply),
EffectiveLengthGroup(group: 3, letter: "a", effectiveLength: 10, category: .supply),
EffectiveLengthGroup(group: 12, letter: "a", effectiveLength: 10, category: .supply),
]
)
)
#expect(response == 105)
}
@Test @Test
func equivalentRectangularDuct() async throws { func equivalentRectangularDuct() async throws {
let response = try await manualD.rectangularSize(.init(round: 7, height: 8)) let response = try await manualD.rectangularSize(round: 7, height: 8)
#expect(response.height == 8) #expect(response.height == 8)
#expect(response.width == 5) #expect(response.width == 5)
} }

View File

@@ -18,7 +18,7 @@ struct ViewControllerTests {
func login() async throws { func login() async throws {
try await withDependencies { try await withDependencies {
$0.viewController = .liveValue $0.viewController = .liveValue
$0.authClient = .failing $0.auth = .failing
} operation: { } operation: {
@Dependency(\.viewController) var viewController @Dependency(\.viewController) var viewController
@@ -31,7 +31,7 @@ struct ViewControllerTests {
func signup() async throws { func signup() async throws {
try await withDependencies { try await withDependencies {
$0.viewController = .liveValue $0.viewController = .liveValue
$0.authClient = .failing $0.auth = .failing
} operation: { } operation: {
@Dependency(\.viewController) var viewController @Dependency(\.viewController) var viewController
@@ -86,7 +86,7 @@ struct ViewControllerTests {
let project = Project.mock let project = Project.mock
let rooms = Room.mock(projectID: project.id) let rooms = Room.mock(projectID: project.id)
let equipment = EquipmentInfo.mock(projectID: project.id) let equipment = EquipmentInfo.mock(projectID: project.id)
let tels = EffectiveLength.mock(projectID: project.id) let tels = EquivalentLength.mock(projectID: project.id)
let componentLosses = ComponentPressureLoss.mock(projectID: project.id) let componentLosses = ComponentPressureLoss.mock(projectID: project.id)
let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms) let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms)
@@ -100,6 +100,12 @@ struct ViewControllerTests {
) )
} }
let mockDuctSizes = DuctSizes.mock(
equipmentInfo: equipment,
rooms: rooms,
trunks: trunks
)
try await withDefaultDependencies { try await withDefaultDependencies {
$0.database.projects.get = { _ in project } $0.database.projects.get = { _ in project }
$0.database.projects.getCompletedSteps = { _ in $0.database.projects.getCompletedSteps = { _ in
@@ -108,13 +114,16 @@ struct ViewControllerTests {
$0.database.projects.getSensibleHeatRatio = { _ in 0.83 } $0.database.projects.getSensibleHeatRatio = { _ in 0.83 }
$0.database.rooms.fetch = { _ in rooms } $0.database.rooms.fetch = { _ in rooms }
$0.database.equipment.fetch = { _ in equipment } $0.database.equipment.fetch = { _ in equipment }
$0.database.effectiveLength.fetch = { _ in tels } $0.database.equivalentLengths.fetch = { _ in tels }
$0.database.effectiveLength.fetchMax = { _ in $0.database.equivalentLengths.fetchMax = { _ in
.init(supply: tels.first, return: tels.last) .init(supply: tels.first, return: tels.last)
} }
$0.database.componentLoss.fetch = { _ in componentLosses } $0.database.componentLosses.fetch = { _ in componentLosses }
$0.projectClient.calculateDuctSizes = { _ in $0.projectClient.calculateRoomDuctSizes = { _ in
.mock(equipmentInfo: equipment, rooms: rooms, trunks: trunks) mockDuctSizes.rooms
}
$0.projectClient.calculateTrunkDuctSizes = { _ in
mockDuctSizes.trunks
} }
} operation: { } operation: {
@Dependency(\.viewController) var viewController @Dependency(\.viewController) var viewController
@@ -163,8 +172,8 @@ struct ViewControllerTests {
return try await withDependencies { return try await withDependencies {
$0.viewController = .liveValue $0.viewController = .liveValue
$0.authClient.currentUser = { user } $0.auth.currentUser = { user }
$0.database.userProfile.fetch = { _ in profile } $0.database.userProfiles.fetch = { _ in profile }
$0.manualD = .liveValue $0.manualD = .liveValue
try await updateDependencies(&$0) try await updateDependencies(&$0)
} operation: { } operation: {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

36
docker/Dockerfile.test Normal file
View File

@@ -0,0 +1,36 @@
FROM docker.io/swift:6.2-noble
# Make sure all system packages are up to date, and install only essential packages.
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get -q install -y \
libjemalloc2 \
ca-certificates \
tzdata \
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
libcurl4 \
# If your app or its dependencies import FoundationXML, also install `libxml2`.
# libxml2 \
sqlite3 \
&& rm -r /var/lib/apt/lists/*
# Set up a build area
WORKDIR /app
# First just resolve dependencies.
# This creates a cached layer that can be reused
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN swift package resolve \
$([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true)
# Copy entire repo into container
COPY . .
ENV SWIFT_BACKTRACE=enable=no
ENV LOG_LEVEL=debug
CMD ["swift", "test"]

6
input.css Normal file
View File

@@ -0,0 +1,6 @@
@import "tailwindcss";
@source not "./tailwindcss";
@source not "./daisyui{,*}.mjs";
@plugin "./daisyui.mjs";

View File

@@ -14,7 +14,20 @@ run:
@swift run App serve --log debug @swift run App serve --log debug
build-docker file="docker/Dockerfile": build-docker file="docker/Dockerfile":
@podman build -f {{file}} -t {{docker_image}}:{{docker_tag}} . @docker build -f {{file}} -t {{docker_image}}:{{docker_tag}} .
run-docker: run-docker:
@podman run -it --rm -v $PWD:/app -p 8080:8080 {{docker_image}}:{{docker_tag}} @docker run -it --rm -v $PWD:/app -p 8080:8080 {{docker_image}}:{{docker_tag}}
test-docker: (build-docker "docker/Dockerfile.test")
@docker run --rm {{docker_image}}:{{docker_tag}} swift test
code-coverage:
@llvm-cov report \
"$(find $(swift build --show-bin-path) -name '*.xctest')" \
-instr-profile=.build/debug/codecov/default.profdata \
-ignore-filename-regex=".build|Tests" \
-use-color
test *ARGS:
@swift test --enable-code-coverage {{ARGS}}

2825
output.css Normal file

File diff suppressed because it is too large Load Diff