diff --git a/package-lock.json b/package-lock.json index 3ab622a79d..ff5563c4be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21022,12 +21022,17 @@ "vaul": "^1.1.2" }, "devDependencies": { + "@testing-library/react": "^16.3.2", "@types/node": "^22.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.4", + "jsdom": "^28.1.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.9" }, "engines": { "node": ">=22.0.0" @@ -21037,6 +21042,138 @@ "react-dom": "^19.2.7" } }, + "packages/loopover-ui-kit/node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "packages/loopover-ui-kit/node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "packages/loopover-ui-kit/node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "packages/loopover-ui-kit/node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "packages/loopover-ui-kit/node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "packages/loopover-ui-kit/node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "packages/loopover-ui-kit/node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -21047,12 +21184,223 @@ "undici-types": "~6.21.0" } }, + "packages/loopover-ui-kit/node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "packages/loopover-ui-kit/node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "packages/loopover-ui-kit/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "packages/loopover-ui-kit/node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "packages/loopover-ui-kit/node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "packages/loopover-ui-kit/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "packages/loopover-ui-kit/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "packages/loopover-ui-kit/node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "packages/loopover-ui-kit/node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "packages/loopover-ui-kit/node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "packages/loopover-ui-kit/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "packages/loopover-ui-kit/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "packages/loopover-ui-kit/node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "packages/loopover-ui-kit/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "packages/loopover-ui-kit/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "packages/loopover-ui-kit/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } } } } diff --git a/packages/loopover-ui-kit/package.json b/packages/loopover-ui-kit/package.json index 68f86ce556..b79bcc72d2 100644 --- a/packages/loopover-ui-kit/package.json +++ b/packages/loopover-ui-kit/package.json @@ -43,7 +43,8 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" }, "peerDependencies": { "react": "^19.2.7", @@ -90,12 +91,17 @@ "vaul": "^1.1.2" }, "devDependencies": { + "@testing-library/react": "^16.3.2", "@types/node": "^22.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.4", + "jsdom": "^28.1.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.9" }, "engines": { "node": ">=22.0.0" diff --git a/packages/loopover-ui-kit/src/components/state-views.test.tsx b/packages/loopover-ui-kit/src/components/state-views.test.tsx new file mode 100644 index 0000000000..39a0357ddc --- /dev/null +++ b/packages/loopover-ui-kit/src/components/state-views.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { EmptyState, ErrorState, LoadingState, StateBoundary } from "./state-views"; + +describe("LoadingState / EmptyState / ErrorState copy (#793)", () => { + it("LoadingState shows its default copy under a polite status role", () => { + render(); + expect(screen.getByRole("status")).toBeTruthy(); + expect(screen.getByText("Loading…")).toBeTruthy(); + }); + + it("LoadingState honors an overridden title", () => { + render(); + expect(screen.getByText("Fetching signals")).toBeTruthy(); + }); + + it("EmptyState shows its default empty copy", () => { + render(); + expect(screen.getByText("Nothing here yet")).toBeTruthy(); + }); + + it("ErrorState shows generic (server-answered) copy under an alert role by default", () => { + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText("Couldn't load this")).toBeTruthy(); + expect(screen.getByText(/Something went wrong fetching this data/)).toBeTruthy(); + }); + + it("ErrorState shows connectivity copy for a network errorKind, and treats timeout the same way", () => { + const { unmount } = render(); + expect(screen.getByText("Can't reach the server")).toBeTruthy(); + expect(screen.getByText(/couldn't reach the API/)).toBeTruthy(); + unmount(); + + render(); + expect(screen.getByText("Can't reach the server")).toBeTruthy(); + }); + + it("ErrorState keeps the generic copy for a non-network (http) errorKind", () => { + render(); + expect(screen.getByText("Couldn't load this")).toBeTruthy(); + }); + + it("ErrorState: an explicit title/description always wins over the errorKind default", () => { + render(); + expect(screen.getByText("Custom title")).toBeTruthy(); + expect(screen.getByText("Custom description")).toBeTruthy(); + }); +}); + +describe("StateBoundary precedence (#793)", () => { + const child =
child content
; + + it("renders loading ahead of error/empty/children when isLoading", () => { + render( + + {child} + , + ); + expect(screen.getByRole("status")).toBeTruthy(); + expect(screen.queryByText("child content")).toBeNull(); + }); + + it("renders error ahead of empty/children when isError (and not loading)", () => { + render( + + {child} + , + ); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.queryByText("child content")).toBeNull(); + }); + + it("renders empty ahead of children when isEmpty (and not loading/error)", () => { + render({child}); + // StateBoundary supplies its own empty default ("No data available yet"); the bare EmptyState default + // ("Nothing here yet") is covered separately above. + expect(screen.getByText("No data available yet")).toBeTruthy(); + expect(screen.queryByText("child content")).toBeNull(); + }); + + it("renders children when no loading/error/empty flag is set", () => { + render({child}); + expect(screen.getByText("child content")).toBeTruthy(); + }); + + it("uses its own generic default error title when no errorKind is given", () => { + render({child}); + expect(screen.getByText("Couldn't load data")).toBeTruthy(); + }); + + it("falls through to ErrorState's network-aware default title when errorKind is a network kind", () => { + render( + + {child} + , + ); + expect(screen.getByText("Can't reach the server")).toBeTruthy(); + }); +}); diff --git a/packages/loopover-ui-kit/src/hooks/use-mobile.test.tsx b/packages/loopover-ui-kit/src/hooks/use-mobile.test.tsx new file mode 100644 index 0000000000..491965e493 --- /dev/null +++ b/packages/loopover-ui-kit/src/hooks/use-mobile.test.tsx @@ -0,0 +1,53 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useIsMobile } from "./use-mobile"; + +// jsdom implements neither window.matchMedia nor a settable innerWidth trigger, so stub matchMedia (capturing its +// `change` listeners) and drive innerWidth directly — the standard shadcn use-mobile test setup. +function stubMatchMedia() { + const listeners = new Set<() => void>(); + const mql = { + matches: false, + media: "", + addEventListener: (_type: string, cb: () => void) => listeners.add(cb), + removeEventListener: (_type: string, cb: () => void) => listeners.delete(cb), + }; + vi.stubGlobal("matchMedia", vi.fn(() => mql)); + return { fireChange: () => { for (const cb of [...listeners]) cb(); } }; +} + +function setInnerWidth(width: number) { + Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: width }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useIsMobile", () => { + it("is true below the 768px breakpoint and false at/above it", () => { + stubMatchMedia(); + + setInnerWidth(500); + const { result: mobile } = renderHook(() => useIsMobile()); + expect(mobile.current).toBe(true); + + setInnerWidth(1024); + const { result: desktop } = renderHook(() => useIsMobile()); + expect(desktop.current).toBe(false); + }); + + it("flips when a matchMedia `change` event fires after innerWidth crosses the breakpoint", () => { + const { fireChange } = stubMatchMedia(); + + setInnerWidth(1024); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + + setInnerWidth(400); + act(() => { + fireChange(); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/packages/loopover-ui-kit/src/utils.test.ts b/packages/loopover-ui-kit/src/utils.test.ts new file mode 100644 index 0000000000..85d5527e16 --- /dev/null +++ b/packages/loopover-ui-kit/src/utils.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { cn, relativeTimeFromNow } from "./utils"; + +describe("cn", () => { + it("merges class names and lets a later Tailwind class win a conflict (tailwind-merge)", () => { + expect(cn("px-2", "text-sm")).toBe("px-2 text-sm"); + // tailwind-merge dedupes conflicting utilities, keeping the last one. + expect(cn("px-2", "px-4")).toBe("px-4"); + }); + + it("drops falsy/conditional entries (clsx)", () => { + expect(cn("a", false, null, undefined, "", "b")).toBe("a b"); + expect(cn("base", { active: true, hidden: false })).toBe("base active"); + }); +}); + +// Ported verbatim from apps/loopover-ui/src/components/site/refresh-meta.test.tsx (#2219) so the two suites +// can't drift, plus the future-clock-skew clamp the issue (#7437) calls out explicitly. +describe("relativeTimeFromNow (#2219)", () => { + const MINUTE_MS = 60_000; + const HOUR_MS = 60 * MINUTE_MS; + const DAY_MS = 24 * HOUR_MS; + const now = Date.UTC(2026, 6, 10, 12, 0, 0); + + it("labels each bucket at and around its boundary", () => { + // seconds bucket, including the exact lower edge and the last second before a minute + expect(relativeTimeFromNow(now, now)).toBe("just now"); + expect(relativeTimeFromNow(now - 59_000, now)).toBe("just now"); + // minutes bucket: 60s flips to 1m; 59m59s still reads 59m + expect(relativeTimeFromNow(now - MINUTE_MS, now)).toBe("1m ago"); + expect(relativeTimeFromNow(now - (HOUR_MS - 1000), now)).toBe("59m ago"); + // hours bucket: 60m flips to 1h; 23h59m still reads 23h + expect(relativeTimeFromNow(now - HOUR_MS, now)).toBe("1h ago"); + expect(relativeTimeFromNow(now - (DAY_MS - MINUTE_MS), now)).toBe("23h ago"); + // days bucket: 24h flips to 1d and keeps counting + expect(relativeTimeFromNow(now - DAY_MS, now)).toBe("1d ago"); + expect(relativeTimeFromNow(now - 3 * DAY_MS - 2 * HOUR_MS, now)).toBe("3d ago"); + }); + + it("clamps a future (clock-skewed) timestamp to 'just now' instead of a negative age", () => { + expect(relativeTimeFromNow(now + 5_000, now)).toBe("just now"); + expect(relativeTimeFromNow(now + DAY_MS, now)).toBe("just now"); + }); +}); diff --git a/packages/loopover-ui-kit/tsconfig.json b/packages/loopover-ui-kit/tsconfig.json index 1992c84b4d..21091c4654 100644 --- a/packages/loopover-ui-kit/tsconfig.json +++ b/packages/loopover-ui-kit/tsconfig.json @@ -17,5 +17,6 @@ "noUnusedParameters": false, "noFallthroughCasesInSwitch": true }, - "include": ["src/**/*.ts", "src/**/*.tsx"] + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "vitest.config.ts", "vitest.setup.ts"] } diff --git a/packages/loopover-ui-kit/vitest.config.ts b/packages/loopover-ui-kit/vitest.config.ts new file mode 100644 index 0000000000..4d8c00fc3e --- /dev/null +++ b/packages/loopover-ui-kit/vitest.config.ts @@ -0,0 +1,18 @@ +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +// Package-local test runner for @loopover/ui-kit (#7437). Mirrors apps/loopover-miner-ui/vitest.config.ts's shape +// (jsdom + the React plugin + a Testing-Library cleanup setup) so the design system's own logic-bearing exports +// are verified directly, not only incidentally through whichever consuming app happens to import them. No +// `coverage` block: this package is deliberately not in the root vitest.config.ts's coverage.include and is not +// Codecov-gated -- the acceptance signal is that this suite runs and passes, not a percentage (see the issue). +export default defineConfig({ + plugins: [react(), tsconfigPaths()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.setup.ts"], + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/packages/loopover-ui-kit/vitest.setup.ts b/packages/loopover-ui-kit/vitest.setup.ts new file mode 100644 index 0000000000..ebe13d846a --- /dev/null +++ b/packages/loopover-ui-kit/vitest.setup.ts @@ -0,0 +1,8 @@ +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +// Unmount React trees between tests so jsdom state never leaks across cases (mirrors +// apps/loopover-miner-ui/vitest.setup.ts's own cleanup). +afterEach(() => { + cleanup(); +});