@7365admin1/layer-common 4.0.3-staging.233 → 4.0.3-staging.235
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/css/primitives.css +21 -1
- package/package.json +3 -2
- package/utils/breadcrumb.test.ts +0 -108
- package/utils/camera-wall.test.ts +0 -634
- package/utils/client-nature.test.ts +0 -77
- package/utils/client-subscription.test.ts +0 -240
- package/utils/console-tier.test.ts +0 -87
- package/utils/dashboard.test.ts +0 -118
- package/utils/data.test.ts +0 -179
- package/utils/hid-discover.test.ts +0 -115
- package/utils/list-page.test.ts +0 -60
- package/utils/nav-sections.test.ts +0 -190
- package/utils/nfc-patrol-report.test.ts +0 -130
- package/utils/promo-code-form.test.ts +0 -437
- package/utils/role.test.ts +0 -69
- package/utils/route-name.test.ts +0 -85
- package/utils/status.test.ts +0 -110
- package/utils/subscription-form.test.ts +0 -284
- package/utils/table-card.test.ts +0 -84
- package/utils/theme.test.ts +0 -679
package/utils/data.test.ts
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
|
|
4
|
-
import { findServiceProviderIdBySite, levelCount } from "./data.ts";
|
|
5
|
-
|
|
6
|
-
const SITE = "68f05fcc0c11062938dadada";
|
|
7
|
-
const OTHER_SITE = "68f05fcc0c11062938d00001";
|
|
8
|
-
const PROVIDER = "68f05fcc0c11062938d00002";
|
|
9
|
-
|
|
10
|
-
test("returns the provider that covers this site", () => {
|
|
11
|
-
assert.equal(
|
|
12
|
-
findServiceProviderIdBySite(
|
|
13
|
-
"service-provider",
|
|
14
|
-
[{ _id: PROVIDER, siteId: SITE }],
|
|
15
|
-
SITE
|
|
16
|
-
),
|
|
17
|
-
PROVIDER
|
|
18
|
-
);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("a non-service-provider account is not scoped by provider at all", () => {
|
|
22
|
-
assert.equal(findServiceProviderIdBySite("customer", [], SITE), null);
|
|
23
|
-
assert.equal(findServiceProviderIdBySite("", undefined, SITE), null);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Returning null here would drop the provider filter and show the whole site -
|
|
28
|
-
* another provider's work orders included. Refusing is the correct behaviour.
|
|
29
|
-
*/
|
|
30
|
-
test("refuses rather than falling back to an unscoped request", () => {
|
|
31
|
-
assert.throws(() =>
|
|
32
|
-
findServiceProviderIdBySite(
|
|
33
|
-
"service-provider",
|
|
34
|
-
[{ _id: PROVIDER, siteId: OTHER_SITE }],
|
|
35
|
-
SITE
|
|
36
|
-
)
|
|
37
|
-
);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The thrown message is rendered verbatim in a toast on six applications, so it
|
|
42
|
-
* is part of the user interface. Assert what it SAYS, not that it throws: the
|
|
43
|
-
* old message passed a "throws" assertion while quoting a raw ObjectId at a
|
|
44
|
-
* security officer and naming no action.
|
|
45
|
-
*/
|
|
46
|
-
test("the message names an action and never quotes an internal id", () => {
|
|
47
|
-
for (const providers of [[{ _id: PROVIDER, siteId: OTHER_SITE }], [], undefined]) {
|
|
48
|
-
let message = "";
|
|
49
|
-
try {
|
|
50
|
-
findServiceProviderIdBySite("service-provider", providers, SITE);
|
|
51
|
-
assert.fail("expected it to refuse");
|
|
52
|
-
} catch (error: any) {
|
|
53
|
-
message = error.message;
|
|
54
|
-
}
|
|
55
|
-
assert.doesNotMatch(message, /[0-9a-f]{24}/i, `leaks an id: ${message}`);
|
|
56
|
-
assert.doesNotMatch(message, /siteId/i, `internal wording: ${message}`);
|
|
57
|
-
assert.match(message, /not linked to this site/i);
|
|
58
|
-
assert.match(message, /administrator/i);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* An account with no serviceProviders field at all used to die on
|
|
64
|
-
* `undefined.find(...)` - a TypeError, which the toast renders as a raw
|
|
65
|
-
* JavaScript error instead of the sentence above.
|
|
66
|
-
*/
|
|
67
|
-
test("an account with no provider list still gets the sentence, not a TypeError", () => {
|
|
68
|
-
assert.throws(
|
|
69
|
-
() => findServiceProviderIdBySite("service-provider", undefined, SITE),
|
|
70
|
-
(error: any) => error.constructor === Error && !/undefined/.test(error.message)
|
|
71
|
-
);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* A block whose record carries no `levels` array used to throw during render
|
|
76
|
-
* (`Cannot read properties of undefined (reading 'length')`), which blanks the
|
|
77
|
-
* entire blocks table - not just the offending row.
|
|
78
|
-
*/
|
|
79
|
-
test("a block with no levels field counts as zero instead of throwing", () => {
|
|
80
|
-
assert.equal(levelCount({ levels: undefined }), 0);
|
|
81
|
-
assert.equal(levelCount({}), 0);
|
|
82
|
-
assert.equal(levelCount(undefined), 0);
|
|
83
|
-
assert.equal(levelCount(null), 0);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("a block with no levels field is not confused with a block that has none", () => {
|
|
87
|
-
assert.equal(levelCount({ levels: [] }), 0);
|
|
88
|
-
assert.equal(levelCount({ levels: [{ name: "1" }, { name: "2" }] }), 2);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("a non-array levels value is counted as zero, not as its own length", () => {
|
|
92
|
-
// The API has been seen to answer with a count rather than the array.
|
|
93
|
-
assert.equal(levelCount({ levels: 7 as unknown as [] }), 0);
|
|
94
|
-
assert.equal(levelCount({ levels: "12" as unknown as [] }), 0);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
import { cameraErrorConverter } from "./data.ts";
|
|
98
|
-
|
|
99
|
-
/** Shape of an ofetch failure: the status and body live under `response`. */
|
|
100
|
-
const apiError = (status: number, message?: string) => ({
|
|
101
|
-
response: { status, _data: message === undefined ? {} : { message } },
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("a duplicate reads as a duplicate", () => {
|
|
105
|
-
assert.match(
|
|
106
|
-
cameraErrorConverter(apiError(400, "ANPR already exist."), "ip"),
|
|
107
|
-
/already exists on this site/
|
|
108
|
-
);
|
|
109
|
-
assert.match(
|
|
110
|
-
cameraErrorConverter(apiError(409), "ip"),
|
|
111
|
-
/already exists on this site/
|
|
112
|
-
);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("a failure that is NOT a duplicate never says duplicate", () => {
|
|
116
|
-
const notDuplicate = [
|
|
117
|
-
cameraErrorConverter(apiError(401), "ip"),
|
|
118
|
-
cameraErrorConverter(apiError(403), "ip"),
|
|
119
|
-
cameraErrorConverter(apiError(404), "ip"),
|
|
120
|
-
cameraErrorConverter(apiError(429), "ip"),
|
|
121
|
-
cameraErrorConverter(apiError(500, "Failed to create ANPR."), "ip"),
|
|
122
|
-
cameraErrorConverter(new Error("Network request failed"), "ip"),
|
|
123
|
-
];
|
|
124
|
-
|
|
125
|
-
for (const message of notDuplicate) {
|
|
126
|
-
assert.doesNotMatch(message, /already exist/i, message);
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("each cause gets its own message", () => {
|
|
131
|
-
assert.match(cameraErrorConverter(apiError(401), "ip"), /session has expired/);
|
|
132
|
-
assert.match(
|
|
133
|
-
cameraErrorConverter(apiError(403), "ip"),
|
|
134
|
-
/do not have permission/
|
|
135
|
-
);
|
|
136
|
-
assert.match(cameraErrorConverter(apiError(404), "ip"), /no longer exists/);
|
|
137
|
-
assert.match(cameraErrorConverter(apiError(429), "ip"), /Too many attempts/);
|
|
138
|
-
assert.match(
|
|
139
|
-
cameraErrorConverter(apiError(500, "Failed to create ANPR."), "ip"),
|
|
140
|
-
/could not save this CCTV camera/
|
|
141
|
-
);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("no response at all reads as a connection problem, not a server refusal", () => {
|
|
145
|
-
assert.match(
|
|
146
|
-
cameraErrorConverter(new Error("Failed to fetch"), "ip"),
|
|
147
|
-
/Could not reach the server/
|
|
148
|
-
);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("a rejected field is named the way the form names it", () => {
|
|
152
|
-
assert.equal(
|
|
153
|
-
cameraErrorConverter(apiError(400, '"host" is required'), "ip"),
|
|
154
|
-
"URL is required."
|
|
155
|
-
);
|
|
156
|
-
assert.equal(
|
|
157
|
-
cameraErrorConverter(apiError(400, '"name" is not allowed to be empty'), "ip"),
|
|
158
|
-
"Camera Name cannot be empty."
|
|
159
|
-
);
|
|
160
|
-
// A field with no friendly label still reads as a sentence.
|
|
161
|
-
assert.equal(
|
|
162
|
-
cameraErrorConverter(apiError(400, '"guardPost" must be a number'), "ip"),
|
|
163
|
-
"guardPost must be a number."
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("a plain 400 sentence from the API is passed through unchanged", () => {
|
|
168
|
-
assert.equal(
|
|
169
|
-
cameraErrorConverter(apiError(400, "Invalid _id format"), "ip"),
|
|
170
|
-
"Invalid _id format"
|
|
171
|
-
);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("the camera type decides the wording", () => {
|
|
175
|
-
assert.match(cameraErrorConverter(apiError(404), "anpr"), /ANPR camera/);
|
|
176
|
-
assert.match(cameraErrorConverter(apiError(404), "ip"), /CCTV camera/);
|
|
177
|
-
// Unknown/absent type falls back to CCTV, which is what the panel defaults to.
|
|
178
|
-
assert.match(cameraErrorConverter(apiError(404)), /CCTV camera/);
|
|
179
|
-
});
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import test from "node:test";
|
|
6
|
-
|
|
7
|
-
import useHidAmico, { isUnknownSiteFieldError } from "../composables/useHidAmico.ts";
|
|
8
|
-
|
|
9
|
-
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
-
|
|
11
|
-
type Call = { url: string; body: Record<string, unknown> };
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* The composable calls the Nuxt auto-import `useNuxtApp().$api(...)`. Install a
|
|
15
|
-
* stub for it so the request body can be read without a Nuxt app.
|
|
16
|
-
*/
|
|
17
|
-
function stubApi(handler: (call: Call) => unknown) {
|
|
18
|
-
const calls: Call[] = [];
|
|
19
|
-
(globalThis as Record<string, unknown>).useNuxtApp = () => ({
|
|
20
|
-
$api: (url: string, options: { body: Record<string, unknown> }) => {
|
|
21
|
-
const call = { url, body: options.body };
|
|
22
|
-
calls.push(call);
|
|
23
|
-
return Promise.resolve(handler(call));
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
return calls;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function fetchError(message: string) {
|
|
30
|
-
return Object.assign(new Error("400 Bad Request"), { data: { message } });
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
test("discoverReader sends site in the request body", async () => {
|
|
34
|
-
const calls = stubApi(() => ({ data: { deviceId: "D1", portals: [] } }));
|
|
35
|
-
|
|
36
|
-
await useHidAmico().discoverReader({
|
|
37
|
-
baseUrl: "https://reader.example",
|
|
38
|
-
username: "operator",
|
|
39
|
-
password: "secret",
|
|
40
|
-
site: "0123456789abcdef01234567",
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
assert.equal(calls.length, 1);
|
|
44
|
-
assert.equal(calls[0].url, "/api/access-management/hid/readers/discover");
|
|
45
|
-
assert.equal(calls[0].body.site, "0123456789abcdef01234567");
|
|
46
|
-
assert.equal(calls[0].body.baseUrl, "https://reader.example");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("discoverReader retries without site when the API predates core #1878", async () => {
|
|
50
|
-
const calls = stubApi((call) => {
|
|
51
|
-
if ("site" in call.body) throw fetchError('"site" is not allowed');
|
|
52
|
-
return { data: { deviceId: "D1", portals: [] } };
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const response = await useHidAmico().discoverReader({
|
|
56
|
-
baseUrl: "https://reader.example",
|
|
57
|
-
username: "operator",
|
|
58
|
-
password: "secret",
|
|
59
|
-
site: "0123456789abcdef01234567",
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
assert.equal(calls.length, 2);
|
|
63
|
-
assert.equal("site" in calls[1].body, false);
|
|
64
|
-
assert.equal(calls[1].body.baseUrl, "https://reader.example");
|
|
65
|
-
assert.deepEqual(response, { data: { deviceId: "D1", portals: [] } });
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("discoverReader does not retry a real refusal", async () => {
|
|
69
|
-
const calls = stubApi(() => {
|
|
70
|
-
throw fetchError("You are not authorized to manage readers for this site.");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
await assert.rejects(
|
|
74
|
-
useHidAmico().discoverReader({
|
|
75
|
-
baseUrl: "https://reader.example",
|
|
76
|
-
username: "operator",
|
|
77
|
-
password: "secret",
|
|
78
|
-
site: "0123456789abcdef01234567",
|
|
79
|
-
}),
|
|
80
|
-
);
|
|
81
|
-
assert.equal(calls.length, 1);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("isUnknownSiteFieldError only matches the unknown-key message", () => {
|
|
85
|
-
assert.equal(isUnknownSiteFieldError(fetchError('"site" is not allowed')), true);
|
|
86
|
-
assert.equal(isUnknownSiteFieldError(new Error('"site" is not allowed')), true);
|
|
87
|
-
assert.equal(isUnknownSiteFieldError(fetchError('"site" is required')), false);
|
|
88
|
-
assert.equal(isUnknownSiteFieldError(fetchError('"baseUrl" is not allowed')), false);
|
|
89
|
-
assert.equal(isUnknownSiteFieldError(fetchError("Unauthorized")), false);
|
|
90
|
-
assert.equal(isUnknownSiteFieldError(null), false);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("the reader form takes site as a prop and puts it in the discover call", () => {
|
|
94
|
-
const source = readFileSync(join(root, "components", "HidReaderForm.vue"), "utf8");
|
|
95
|
-
assert.match(source, /site:\s*\{\s*type:\s*String/);
|
|
96
|
-
assert.match(source, /discoverReader\(\{[\s\S]*?site:\s*props\.site[\s\S]*?\}\)/);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("reader management passes its site prop down to the form, ONCE", () => {
|
|
100
|
-
const source = readFileSync(join(root, "components", "HidReaderManagement.vue"), "utf8");
|
|
101
|
-
const tag = source.match(/<HidReaderForm[\s\S]*?\/>/)?.[0] ?? "";
|
|
102
|
-
|
|
103
|
-
// Bound to `props.site`, which is the only name for it that exists in this
|
|
104
|
-
// component - a bare `site` resolves to nothing.
|
|
105
|
-
assert.match(tag, /:site="props\.site"/);
|
|
106
|
-
|
|
107
|
-
/*
|
|
108
|
-
* And exactly once. It was written twice - `props.site` and then a bare
|
|
109
|
-
* `site` - and Vite's Vue plugin treats a duplicate attribute as a COMPILE
|
|
110
|
-
* ERROR, so every consuming application's `nuxt build` failed on this file.
|
|
111
|
-
* The layer's own playground build does not import this component, so
|
|
112
|
-
* nothing here caught it; the first sign was a consumer failing to build.
|
|
113
|
-
*/
|
|
114
|
-
assert.equal((tag.match(/:site=/g) ?? []).length, 1, tag);
|
|
115
|
-
});
|
package/utils/list-page.test.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { describe, it } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { readListPage, NO_PAGE_RANGE } from "./list-page.ts";
|
|
4
|
-
|
|
5
|
-
const rows = (n: number, from = 1) =>
|
|
6
|
-
Array.from({ length: n }, (_, i) => ({ _id: `row-${from + i}` }));
|
|
7
|
-
|
|
8
|
-
describe("readListPage", () => {
|
|
9
|
-
it("keeps the server's total when it is bigger than the page - the failing case", () => {
|
|
10
|
-
// 47 announcements served 10 at a time. Deriving from the page gives
|
|
11
|
-
// "10" and one page, which is the defect this replaces.
|
|
12
|
-
const got = readListPage({ items: rows(10), pages: 5, pageRange: "1-10 of 47" });
|
|
13
|
-
|
|
14
|
-
assert.equal(got.items.length, 10);
|
|
15
|
-
assert.equal(got.pages, 5);
|
|
16
|
-
assert.equal(got.pageRange, "1-10 of 47");
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("keeps the server's page 2 range rather than re-slicing", () => {
|
|
20
|
-
const got = readListPage({ items: rows(10, 11), pages: 5, pageRange: "11-20 of 47" });
|
|
21
|
-
|
|
22
|
-
assert.equal(got.items.length, 10);
|
|
23
|
-
assert.equal(got.items[0]._id, "row-11");
|
|
24
|
-
assert.equal(got.pageRange, "11-20 of 47");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("passes a single page through unchanged", () => {
|
|
28
|
-
const got = readListPage({ items: rows(7), pages: 1, pageRange: "1-7 of 7" });
|
|
29
|
-
|
|
30
|
-
assert.deepEqual(
|
|
31
|
-
{ pages: got.pages, pageRange: got.pageRange, n: got.items.length },
|
|
32
|
-
{ pages: 1, pageRange: "1-7 of 7", n: 7 }
|
|
33
|
-
);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("reports a genuinely empty list as the server described it", () => {
|
|
37
|
-
const got = readListPage({ items: [], pages: 0, pageRange: "0 - 0 of 0" });
|
|
38
|
-
|
|
39
|
-
assert.deepEqual(got, { items: [], pages: 0, pageRange: "0 - 0 of 0" });
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("does not invent a zero when the request failed and there is no reply", () => {
|
|
43
|
-
for (const reply of [null, undefined]) {
|
|
44
|
-
const got = readListPage(reply);
|
|
45
|
-
|
|
46
|
-
assert.deepEqual(got.items, []);
|
|
47
|
-
assert.equal(got.pages, 0);
|
|
48
|
-
// "0 of 0" would be a statement about the data. This is not one.
|
|
49
|
-
assert.equal(got.pageRange, NO_PAGE_RANGE);
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("does not fall back to the page length when the envelope omits its totals", () => {
|
|
54
|
-
const got = readListPage({ items: rows(10) });
|
|
55
|
-
|
|
56
|
-
assert.equal(got.pages, 0);
|
|
57
|
-
assert.equal(got.pageRange, NO_PAGE_RANGE);
|
|
58
|
-
assert.notEqual(got.pageRange, "1-10 of 10");
|
|
59
|
-
});
|
|
60
|
-
});
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
|
|
4
|
-
import { NAV_SECTIONS, withSections } from "./nav-sections.ts";
|
|
5
|
-
|
|
6
|
-
/** The Security menu, item for item and in `web-app-security`'s own order. */
|
|
7
|
-
const SECURITY = [
|
|
8
|
-
"Dashboard",
|
|
9
|
-
"Feedbacks",
|
|
10
|
-
"Work Orders",
|
|
11
|
-
"Vehicle Mgmt",
|
|
12
|
-
"Building Mgmt",
|
|
13
|
-
"Visitor Mgmt",
|
|
14
|
-
"Pass & Key Mgmt",
|
|
15
|
-
"HID Face Reader",
|
|
16
|
-
"Daily Occurrence Books",
|
|
17
|
-
"Manpower",
|
|
18
|
-
"Virtual Patrol",
|
|
19
|
-
"Robot Mgmt",
|
|
20
|
-
"Incident Reports",
|
|
21
|
-
"Bulletin Board",
|
|
22
|
-
"Attendance",
|
|
23
|
-
"My Attendance",
|
|
24
|
-
"Invitations",
|
|
25
|
-
"Members",
|
|
26
|
-
"Roles & Permissions",
|
|
27
|
-
"Settings",
|
|
28
|
-
].map((title) => ({ title }));
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* THE RULE THAT MATTERS. Grouping may reorder the rail; it may never change
|
|
32
|
-
* the menu. Same objects, same count - nothing added, hidden or duplicated.
|
|
33
|
-
*/
|
|
34
|
-
test("every menu item comes back exactly once, and nothing else does", () => {
|
|
35
|
-
const out = withSections(SECURITY);
|
|
36
|
-
|
|
37
|
-
assert.equal(out.length, SECURITY.length);
|
|
38
|
-
assert.deepEqual(
|
|
39
|
-
new Set(out.map((e) => e.item)),
|
|
40
|
-
new Set(SECURITY)
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("Security is labelled the way the design draws it", () => {
|
|
45
|
-
const labelled = withSections(SECURITY)
|
|
46
|
-
.filter((e) => e.section)
|
|
47
|
-
.map((e) => `${e.section}: ${e.item.title}`);
|
|
48
|
-
|
|
49
|
-
assert.deepEqual(labelled, [
|
|
50
|
-
"OVERVIEW: Dashboard",
|
|
51
|
-
"SERVICE DESK: Feedbacks",
|
|
52
|
-
"PROPERTY: Vehicle Mgmt",
|
|
53
|
-
"SECURITY OPERATIONS: HID Face Reader",
|
|
54
|
-
"COMMUNITY: Bulletin Board",
|
|
55
|
-
"WORKFORCE: Attendance",
|
|
56
|
-
"ADMINISTRATION: Invitations",
|
|
57
|
-
]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* A permission-gated menu is a SUBSET, and every application's is different.
|
|
62
|
-
* Dropping the items a user cannot see must not leave a heading behind or move
|
|
63
|
-
* one onto the wrong item.
|
|
64
|
-
*/
|
|
65
|
-
test("a user who can only see three modules gets three correct headings", () => {
|
|
66
|
-
const out = withSections(
|
|
67
|
-
[{ title: "Dashboard" }, { title: "Work Orders" }, { title: "Settings" }]
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
assert.deepEqual(
|
|
71
|
-
out.map((e) => e.section),
|
|
72
|
-
["OVERVIEW", "SERVICE DESK", "ADMINISTRATION"]
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* The eleven applications add menu items without touching this layer. An
|
|
78
|
-
* unknown title must appear where its author put it, under the heading it
|
|
79
|
-
* follows - never suppressed, and never given a heading of its own.
|
|
80
|
-
*/
|
|
81
|
-
test("an unmapped module inherits the section above it", () => {
|
|
82
|
-
const out = withSections([
|
|
83
|
-
{ title: "Dashboard" },
|
|
84
|
-
{ title: "Something New" },
|
|
85
|
-
{ title: "Members" },
|
|
86
|
-
]);
|
|
87
|
-
|
|
88
|
-
assert.deepEqual(
|
|
89
|
-
out.map((e) => e.section),
|
|
90
|
-
["OVERVIEW", "", "ADMINISTRATION"]
|
|
91
|
-
);
|
|
92
|
-
assert.equal(out[1].item.title, "Something New");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("an unmapped FIRST item prints no heading rather than a blank one", () => {
|
|
96
|
-
const out = withSections([{ title: "Something New" }, { title: "Members" }]);
|
|
97
|
-
|
|
98
|
-
assert.deepEqual(
|
|
99
|
-
out.map((e) => e.section),
|
|
100
|
-
["", "ADMINISTRATION"]
|
|
101
|
-
);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* The applications do not agree on order: Landscape lists Feedbacks after its
|
|
106
|
-
* equipment modules. Those two Feedbacks/Work Orders items are collected under
|
|
107
|
-
* ONE SERVICE DESK heading instead of printing the heading twice.
|
|
108
|
-
*/
|
|
109
|
-
test("a section that comes back later is grouped, not labelled twice", () => {
|
|
110
|
-
const out = withSections([
|
|
111
|
-
{ title: "Feedbacks" },
|
|
112
|
-
{ title: "Equipment Items" },
|
|
113
|
-
{ title: "Work Orders" },
|
|
114
|
-
]);
|
|
115
|
-
|
|
116
|
-
assert.deepEqual(
|
|
117
|
-
out.map((e) => `${e.section}|${e.item.title}`),
|
|
118
|
-
[
|
|
119
|
-
"SERVICE DESK|Feedbacks",
|
|
120
|
-
"|Work Orders",
|
|
121
|
-
"EQUIPMENT|Equipment Items",
|
|
122
|
-
]
|
|
123
|
-
);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Property Management is the screen that showed the defect: its own menu order
|
|
128
|
-
* printed SERVICE DESK, PROPERTY and SECURITY OPERATIONS twice each.
|
|
129
|
-
*/
|
|
130
|
-
test("Property Management prints each heading exactly once", () => {
|
|
131
|
-
const PM = [
|
|
132
|
-
"Dashboard",
|
|
133
|
-
"Service Providers",
|
|
134
|
-
"Feedbacks",
|
|
135
|
-
"Work Orders",
|
|
136
|
-
"Facility Mgmt",
|
|
137
|
-
"Pass & Key Mgmt",
|
|
138
|
-
"HID Face Reader",
|
|
139
|
-
"Online Forms",
|
|
140
|
-
"NFC Patrol",
|
|
141
|
-
"Bulletin Board",
|
|
142
|
-
"Bulletin Videos",
|
|
143
|
-
"Visitor Mgmt",
|
|
144
|
-
"Document Mgmt",
|
|
145
|
-
"Access Mgmt",
|
|
146
|
-
].map((title) => ({ title }));
|
|
147
|
-
|
|
148
|
-
const out = withSections(PM);
|
|
149
|
-
const headings = out.map((e) => e.section).filter(Boolean);
|
|
150
|
-
|
|
151
|
-
assert.deepEqual(headings, [
|
|
152
|
-
"OVERVIEW",
|
|
153
|
-
"SERVICE DESK",
|
|
154
|
-
"PROPERTY",
|
|
155
|
-
"SECURITY OPERATIONS",
|
|
156
|
-
"COMMUNITY",
|
|
157
|
-
]);
|
|
158
|
-
assert.equal(new Set(headings).size, headings.length);
|
|
159
|
-
assert.deepEqual(
|
|
160
|
-
out.map((e) => e.item.title),
|
|
161
|
-
[
|
|
162
|
-
"Dashboard",
|
|
163
|
-
"Service Providers",
|
|
164
|
-
"Feedbacks",
|
|
165
|
-
"Work Orders",
|
|
166
|
-
"Online Forms",
|
|
167
|
-
"Facility Mgmt",
|
|
168
|
-
"Pass & Key Mgmt",
|
|
169
|
-
"Visitor Mgmt",
|
|
170
|
-
"Document Mgmt",
|
|
171
|
-
"Access Mgmt",
|
|
172
|
-
"HID Face Reader",
|
|
173
|
-
"NFC Patrol",
|
|
174
|
-
"Bulletin Board",
|
|
175
|
-
"Bulletin Videos",
|
|
176
|
-
]
|
|
177
|
-
);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("a missing or empty title does not throw", () => {
|
|
181
|
-
const out = withSections([{}, { title: "" }] as Array<{ title?: string }>);
|
|
182
|
-
assert.equal(out.length, 2);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
/** Every section name is the uppercase form the type scale is drawn for. */
|
|
186
|
-
test("section names are uppercase", () => {
|
|
187
|
-
for (const section of Object.values(NAV_SECTIONS)) {
|
|
188
|
-
assert.equal(section, section.toUpperCase(), section);
|
|
189
|
-
}
|
|
190
|
-
});
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
|
|
4
|
-
import { collectAllPages, mapLogsToMonthlyRows } from "./nfc-patrol-report.ts";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* THE DEFECT THIS FILE EXISTS FOR.
|
|
8
|
-
*
|
|
9
|
-
* The yearly patrol-compliance table was built from `page: 1, limit: 100` of a
|
|
10
|
-
* paged endpoint and never looked at `pages`. A route patrolled daily produces
|
|
11
|
-
* ~300 runs a year, so months past the first page rendered "0 / 0% / 0 / 0%" -
|
|
12
|
-
* indistinguishable from a month in which every checkpoint was missed, in a
|
|
13
|
-
* table a manager can print and hand to a client.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** 12 months x 25 runs x (4 completed + 1 skipped). Every month is 80%/20%. */
|
|
17
|
-
function yearOfLogs() {
|
|
18
|
-
const logs: Record<string, any>[] = [];
|
|
19
|
-
for (let m = 0; m < 12; m++) {
|
|
20
|
-
for (let d = 1; d <= 25; d++) {
|
|
21
|
-
const checkPoints = [
|
|
22
|
-
{ status: "Completed" },
|
|
23
|
-
{ status: "Completed" },
|
|
24
|
-
{ status: "Completed" },
|
|
25
|
-
{ status: "Completed" },
|
|
26
|
-
{ status: "Skipped" },
|
|
27
|
-
];
|
|
28
|
-
logs.push({
|
|
29
|
-
date: `2026-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T08:00:00.000Z`,
|
|
30
|
-
checkPoints,
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return logs;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function pagedFetcher(all: Record<string, any>[], limit = 100) {
|
|
38
|
-
const pages = Math.ceil(all.length / limit);
|
|
39
|
-
const seen: number[] = [];
|
|
40
|
-
return {
|
|
41
|
-
seen,
|
|
42
|
-
fetchPage: async (page: number) => {
|
|
43
|
-
seen.push(page);
|
|
44
|
-
return { items: all.slice((page - 1) * limit, page * limit), pages };
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
test("every page the endpoint reports is read, not just the first", async () => {
|
|
50
|
-
const all = yearOfLogs();
|
|
51
|
-
const { fetchPage, seen } = pagedFetcher(all);
|
|
52
|
-
|
|
53
|
-
const items = await collectAllPages(fetchPage);
|
|
54
|
-
|
|
55
|
-
assert.equal(items.length, all.length, "all 300 runs collected");
|
|
56
|
-
assert.deepEqual(seen, [1, 2, 3], "walked every page the reply declared");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("a full year reports the same rate in every month", async () => {
|
|
60
|
-
const items = await collectAllPages(pagedFetcher(yearOfLogs()).fetchPage);
|
|
61
|
-
const rows = mapLogsToMonthlyRows(items);
|
|
62
|
-
|
|
63
|
-
assert.equal(rows.length, 12);
|
|
64
|
-
for (const row of rows) {
|
|
65
|
-
assert.equal(row.checked, 100, `${row.month} checked`);
|
|
66
|
-
assert.equal(row.missed, 25, `${row.month} missed`);
|
|
67
|
-
assert.equal(row.checkedPercentage, 80, `${row.month} % checked`);
|
|
68
|
-
assert.equal(row.missedPercentage, 20, `${row.month} % missed`);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("only the first page would have zeroed the rest of the year", () => {
|
|
73
|
-
// What the screen used to do: one page of 100, the other 200 runs dropped.
|
|
74
|
-
const firstPageOnly = yearOfLogs().slice(0, 100);
|
|
75
|
-
const rows = mapLogsToMonthlyRows(firstPageOnly);
|
|
76
|
-
|
|
77
|
-
assert.equal(rows[0].checkedPercentage, 80, "January measured");
|
|
78
|
-
assert.equal(rows[4].checked, 0, "May had no runs on page 1");
|
|
79
|
-
assert.equal(
|
|
80
|
-
rows[4].checkedPercentage,
|
|
81
|
-
null,
|
|
82
|
-
"and it must NOT read 0% - nothing was measured",
|
|
83
|
-
);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("a month with no checkpoint due reports no rate at all", () => {
|
|
87
|
-
const rows = mapLogsToMonthlyRows([]);
|
|
88
|
-
|
|
89
|
-
for (const row of rows) {
|
|
90
|
-
assert.equal(row.checked, 0);
|
|
91
|
-
assert.equal(row.missed, 0);
|
|
92
|
-
assert.equal(row.checkedPercentage, null);
|
|
93
|
-
assert.equal(row.missedPercentage, null);
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("a month where every checkpoint was missed still reports 0%", () => {
|
|
98
|
-
const rows = mapLogsToMonthlyRows([
|
|
99
|
-
{ date: "2026-03-04T08:00:00.000Z", checkPoints: [{ status: "Skipped" }, { status: "Skipped" }] },
|
|
100
|
-
]);
|
|
101
|
-
|
|
102
|
-
assert.equal(rows[2].checkedPercentage, 0, "0% here is a real measurement");
|
|
103
|
-
assert.equal(rows[2].missedPercentage, 100);
|
|
104
|
-
assert.equal(rows[0].checkedPercentage, null, "January measured nothing");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("a single-page reply is not re-fetched", async () => {
|
|
108
|
-
const { fetchPage, seen } = pagedFetcher(yearOfLogs().slice(0, 10));
|
|
109
|
-
await collectAllPages(fetchPage);
|
|
110
|
-
assert.deepEqual(seen, [1]);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("a reply with no page count is treated as one page", async () => {
|
|
114
|
-
const calls: number[] = [];
|
|
115
|
-
const items = await collectAllPages(async (page) => {
|
|
116
|
-
calls.push(page);
|
|
117
|
-
return { items: [{ date: "2026-01-02T00:00:00.000Z", checkPoints: [] }] };
|
|
118
|
-
});
|
|
119
|
-
assert.deepEqual(calls, [1]);
|
|
120
|
-
assert.equal(items.length, 1);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("undated or malformed logs are skipped, not counted into January", () => {
|
|
124
|
-
const rows = mapLogsToMonthlyRows([
|
|
125
|
-
{ date: "not-a-date", checkPoints: [{ status: "Completed" }] },
|
|
126
|
-
{ checkPoints: [{ status: "Completed" }] },
|
|
127
|
-
]);
|
|
128
|
-
assert.equal(rows[0].checked, 0);
|
|
129
|
-
assert.equal(rows[0].checkedPercentage, null);
|
|
130
|
-
});
|