diff --git a/eslint.config.js b/eslint.config.js index b19330b..cd2d447 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,23 +1,38 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js" +import globals from "globals" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" +import tseslint from "typescript-eslint" +import { defineConfig, globalIgnores } from "eslint/config" export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, - reactHooks.configs['recommended-latest'], + reactHooks.configs["recommended-latest"], reactRefresh.configs.vite, ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, + }, + { + files: ["test/**/*.{ts,tsx}"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, }, ]) diff --git a/src/App.css b/src/App.css index ab28aa0..8ce14c8 100644 --- a/src/App.css +++ b/src/App.css @@ -82,6 +82,10 @@ button.movePage:hover{ } +#root { + display: flex; + flex-direction: column; +} header { position: sticky; @@ -96,6 +100,7 @@ header { align-items: center; justify-content: center; + background-color: var(--accent-color); backdrop-filter: blur(10px); z-index: 1; /* Otherwise any translated elements render above the blur?? */ } @@ -121,6 +126,14 @@ main { flex-wrap: wrap; } +.min-height-0 { + min-height: 0; +} + +.scroll-y { + overflow-y: scroll; +} + .align-center { align-items: center; } @@ -141,6 +154,10 @@ main { gap: 1rem; } +.margin-0 { + margin: 0; +} + .padding-sm { padding: .25rem; } @@ -150,7 +167,19 @@ main { .padding-lg { padding: 1rem; } +.padding-b-sm { + padding-bottom: .25rem; +} +.padding-b-md { + padding-bottom: .5rem; +} +.padding-b-lg { + padding-bottom: 1rem; +} +.round-sm, .round-md, .round-lg { + overflow: hidden; +} .round-sm { border-radius: .25rem; } @@ -159,4 +188,59 @@ main { } .round-lg { border-radius: 1rem; -} \ No newline at end of file +} + +.border-sm { + border: 1px solid canvastext; +} +.border-md { + border: 2px solid canvastext; +} +.border-lg { + border: 3px solid canvastext; +} + +.font-small { + font-size: .75rem; +} +.font-medium { + font-size: 1rem; +} +.font-large { + font-size: 1.25rem; +} +.mono { + font-family: ui-monospace, monospace; +} +.bold { + font-weight: bold; +} + + +.clickable { + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} +.user-select-all { + -webkit-user-select: all; + user-select: all; +} +.user-select-none { + -webkit-user-select: none; + user-select: none; +} +button.no-button { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} diff --git a/src/App.tsx b/src/App.tsx index 968c979..70fc815 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,23 +4,31 @@ import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' import Robot from './pages/Robot/Robot.tsx'; import VisProg from "./pages/VisProgPage/VisProg.tsx"; +import {useState} from "react"; +import Logging from "./components/Logging/Logging.tsx"; function App(){ + const [showLogs, setShowLogs] = useState(false); + return ( -
+ <>
Home +
-
- - } /> - } /> - } /> - } /> - -
-
- ) +
+
+ + } /> + } /> + } /> + } /> + +
+ {showLogs && } +
+ + ); } export default App diff --git a/src/components/Logging/Filters.module.css b/src/components/Logging/Filters.module.css new file mode 100644 index 0000000..405560c --- /dev/null +++ b/src/components/Logging/Filters.module.css @@ -0,0 +1,34 @@ +.filter-root { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.filter-panel { + position: absolute; + display: flex; + flex-direction: column; + gap: .25rem; + top: 0; + right: 0; + z-index: 1; + background: canvas; + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); + width: 300px; + + *:first-child { + margin-top: 0; + } + *:last-child { + margin-bottom: 0; + } +} + +button.deletable { + cursor: pointer; + + &:hover { + text-decoration: line-through; + } +} diff --git a/src/components/Logging/Filters.tsx b/src/components/Logging/Filters.tsx new file mode 100644 index 0000000..446a9c6 --- /dev/null +++ b/src/components/Logging/Filters.tsx @@ -0,0 +1,200 @@ +import {useEffect, useRef, useState} from "react"; + +import type {LogFilterPredicate} from "./useLogs.ts"; + +import styles from "./Filters.module.css"; + +type Setter = (value: T | ((prev: T) => T)) => void; + +const optionMapping = new Map([ + ["ALL", 0], + ["DEBUG", 10], + ["INFO", 20], + ["WARNING", 30], + ["ERROR", 40], + ["CRITICAL", 50], + ["NONE", 999_999_999_999], // It is technically possible to have a higher level, but this is fine +]); + +function LevelPredicateElement({ + name, + level, + setLevel, + onDelete, +}: { + name: string; + level: string; + setLevel: (level: string) => void; + onDelete?: () => void; +}) { + const normalizedName = name.split(".").pop() || name; + + return
+ + +
+} + +const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level"; + +function GlobalLevelFilter({ + filterPredicates, + setFilterPredicates, +}: { + filterPredicates: Map; + setFilterPredicates: Setter>; +}) { + const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value || "ALL"; + const setSelected = (selected: string | null) => { + if (!selected || !optionMapping.has(selected)) return; + + setFilterPredicates((curr) => { + const next = new Map(curr); + next.set(GLOBAL_LOG_LEVEL_PREDICATE_KEY, { + predicate: (record) => record.levelno >= optionMapping.get(selected)!, + priority: 0, + value: selected, + }); + return next; + }); + } + + useEffect(() => { + if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return; + setSelected("INFO"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Run only once when the component mounts, not when anything changes + + return ; +} + +const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_"; + +function AgentLevelFilters({ + filterPredicates, + setFilterPredicates, + agentNames, +}: { + filterPredicates: Map; + setFilterPredicates: Setter>; + agentNames: Set; +}) { + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + + // Click outside to close + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + setOpen(false); + e.preventDefault(); // Don't exit fullscreen mode + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const agentPredicates = [...filterPredicates.keys()].filter((key) => + key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX)); + + /** + * Create or change the predicate for an agent. If the level is not given, the global level is used. + * @param agentName The name of the agent. + * @param level The level to filter by. If not given, the global level is used. + */ + const setAgentPredicate = (agentName: string, level?: string ) => { + level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL"; + setFilterPredicates((prev) => { + const next = new Map(prev); + next.set(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName, { + predicate: (record) => record.name === agentName + ? record.levelno >= optionMapping.get(level!)! + : null, + priority: 1, + value: {agentName, level}, + }); + return next; + }); + } + + const deleteAgentPredicate = (agentName: string) => { + setFilterPredicates((curr) => { + const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName; + if (!curr.has(fullName)) return curr; // Return unchanged, no re-render + const next = new Map(curr); + next.delete(fullName); + return next; + }); + } + + return <> + {agentPredicates.map((key) => { + const {agentName, level} = filterPredicates.get(key)!.value; + + return setAgentPredicate(agentName, level)} + onDelete={() => deleteAgentPredicate(agentName)} + />; + })} +
+ + +
+ ; +} + +export default function Filters({ + filterPredicates, + setFilterPredicates, + agentNames, +}: { + filterPredicates: Map; + setFilterPredicates: Setter>; + agentNames: Set; +}) { + return
+ + +
; +} diff --git a/src/components/Logging/Logging.module.css b/src/components/Logging/Logging.module.css new file mode 100644 index 0000000..6fc2988 --- /dev/null +++ b/src/components/Logging/Logging.module.css @@ -0,0 +1,39 @@ +.logging-container { + box-sizing: border-box; + + width: max(30dvw, 500px); + flex-shrink: 0; + + box-shadow: 0 0 1rem black; + padding: 1rem 1rem 0 1rem; +} + +.no-numbers { + list-style-type: none; + counter-reset: none; + padding-inline-start: 0; +} + +.log-container { + margin-bottom: .5rem; + + .accented-0, .accented-10 { + background-color: color-mix(in oklab, canvas, rgb(159, 159, 159) 35%) + } + .accented-20 { + background-color: color-mix(in oklab, canvas, green 35%) + } + .accented-30 { + background-color: color-mix(in oklab, canvas, yellow 35%) + } + .accented-40, .accented-50 { + background-color: color-mix(in oklab, canvas, red 35%) + } +} + +.floating-button { + position: fixed; + bottom: 1rem; + right: 1rem; + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); +} \ No newline at end of file diff --git a/src/components/Logging/Logging.tsx b/src/components/Logging/Logging.tsx new file mode 100644 index 0000000..ede0bcc --- /dev/null +++ b/src/components/Logging/Logging.tsx @@ -0,0 +1,129 @@ +import {useEffect, useRef, useState} from "react"; +import {create} from "zustand"; + +import formatDuration from "../../utils/formatDuration.ts"; +import {type LogFilterPredicate, type LogRecord, useLogs} from "./useLogs.ts"; +import Filters from "./Filters.tsx"; +import {type Cell, useCell} from "../../utils/cellStore.ts"; + +import styles from "./Logging.module.css"; + +type LoggingSettings = { + showRelativeTime: boolean; + setShowRelativeTime: (showRelativeTime: boolean) => void; + scrollToBottom: boolean; + setScrollToBottom: (scrollToBottom: boolean) => void; +}; + +const useLoggingSettings = create((set) => ({ + showRelativeTime: false, + setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }), + scrollToBottom: true, + setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }), +})); + +function LogMessage({ + recordCell, + onUpdate, +}: { + recordCell: Cell, + onUpdate?: () => void, +}) { + const { showRelativeTime, setShowRelativeTime } = useLoggingSettings(); + const record = useCell(recordCell); + + /** + * Normalizes the log level number to a multiple of 10, for which there are CSS styles. + */ + const normalizedLevelNo = (() => { + // By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color. + if (record.levelno >= 50) return 50; + + return Math.round(record.levelno / 10) * 10; + })(); + + const normalizedName = record.name.split(".").pop() || record.name; + + useEffect(() => { + if (onUpdate) onUpdate(); + }, [record, onUpdate]); + + return
+
+ {record.levelname} + setShowRelativeTime(!showRelativeTime)} + >{showRelativeTime + ? formatDuration(record.relativeCreated) + : new Date(record.created * 1000).toLocaleTimeString() + } +
+
+ {normalizedName} + {record.message} +
+
; +} + +function LogMessages({ recordCells }: { recordCells: Cell[] }) { + const scrollableRef = useRef(null); + const lastElementRef = useRef(null) + const { scrollToBottom, setScrollToBottom } = useLoggingSettings(); + + useEffect(() => { + if (!scrollableRef.current) return; + const currentScrollableRef = scrollableRef.current; + + const handleScroll = () => setScrollToBottom(false); + + currentScrollableRef.addEventListener("wheel", handleScroll); + currentScrollableRef.addEventListener("touchmove", handleScroll); + + return () => { + currentScrollableRef.removeEventListener("wheel", handleScroll); + currentScrollableRef.removeEventListener("touchmove", handleScroll); + } + }, [scrollableRef, setScrollToBottom]); + + function scrollLastElementIntoView(force = false) { + if ((!scrollToBottom && !force) || !lastElementRef.current) return; + lastElementRef.current.scrollIntoView({ behavior: "smooth" }); + } + + return
+
    + {recordCells.map((recordCell, i) => ( +
  1. + +
  2. + ))} +
  3. +
+ {!scrollToBottom && } +
; +} + +export default function Logging() { + const [filterPredicates, setFilterPredicates] = useState(new Map()); + const { filteredLogs, distinctNames } = useLogs(filterPredicates) + + return
+
+

Logs

+ +
+ +
; +} diff --git a/src/components/Logging/useLogs.ts b/src/components/Logging/useLogs.ts new file mode 100644 index 0000000..76eed92 --- /dev/null +++ b/src/components/Logging/useLogs.ts @@ -0,0 +1,146 @@ +import {useCallback, useEffect, useRef, useState} from "react"; + +import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts"; +import {cell, type Cell} from "../../utils/cellStore.ts"; + +export type LogRecord = { + name: string; + message: string; + levelname: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string; + levelno: number; + created: number; + relativeCreated: number; + reference?: string; + firstCreated: number; + firstRelativeCreated: number; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LogFilterPredicate = PriorityFilterPredicate & { value: any }; + +export function useLogs(filterPredicates: Map) { + const [distinctNames, setDistinctNames] = useState>(new Set()); + const [filtered, setFiltered] = useState[]>([]); + + const sseRef = useRef(null); + const filtersRef = useRef(filterPredicates); + const logsRef = useRef([]); + + /** Map to store the first message for each reference, instance can be updated to change contents. */ + const firstByRefRef = useRef>>(new Map()); + + /** + * Apply the filter predicates to a log record. + * @param log The log record to apply the filters to. + * @returns `true` if the record passes. + */ + const applyFilters = useCallback((log: LogRecord) => + applyPriorityPredicates(log, [...filtersRef.current.values()]), []); + + /** Recomputes the entire filtered list. Use when filter predicates change. */ + const recomputeFiltered = useCallback(() => { + const newFiltered: Cell[] = []; + firstByRefRef.current = new Map(); + + for (const message of logsRef.current) { + const messageCell = cell({ + ...message, + firstCreated: message.created, + firstRelativeCreated: message.relativeCreated, + }); + + if (message.reference) { + const first = firstByRefRef.current.get(message.reference); + if (first) { + // Update the first's contents + first.set((prev) => ({ + ...message, + firstCreated: prev.firstCreated ?? prev.created, + firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated, + })); + + // Don't add it to the list again + continue; + } else { + // Add the first message with this reference to the registry + firstByRefRef.current.set(message.reference, messageCell); + } + } + + if (applyFilters(message)) { + newFiltered.push(messageCell); + } + } + + setFiltered(newFiltered); + }, [applyFilters, setFiltered]); + + // Reapply filters to all logs, only when filters change + useEffect(() => { + filtersRef.current = filterPredicates; + recomputeFiltered(); + }, [filterPredicates, recomputeFiltered]); + + /** + * Handle a new log message. Updates the filtered list and to the full history. + * @param message The new log message. + */ + const handleNewMessage = useCallback((message: LogRecord) => { + // Add to the full history for re-filtering on filter changes + logsRef.current.push(message); + + setDistinctNames((prev) => { + if (prev.has(message.name)) return prev; + const newSet = new Set(prev); + newSet.add(message.name); + return newSet; + }); + + const messageCell = cell({ + ...message, + firstCreated: message.created, + firstRelativeCreated: message.relativeCreated, + }); + + if (message.reference) { + const first = firstByRefRef.current.get(message.reference); + if (first) { + // Update the first's contents + first.set((prev) => ({ + ...message, + firstCreated: prev.firstCreated ?? prev.created, + firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated, + })); + + // Don't add it to the list again + return; + } else { + // Add the first message with this reference to the registry + firstByRefRef.current.set(message.reference, messageCell); + } + } + + if (applyFilters(message)) { + setFiltered((curr) => [...curr, messageCell]); + } + }, [applyFilters, setFiltered]); + + useEffect(() => { + if (sseRef.current) return; + + const es = new EventSource("http://localhost:8000/logs/stream"); + sseRef.current = es; + + es.onmessage = (event) => { + const data: LogRecord = JSON.parse(event.data); + handleNewMessage(data); + }; + + return () => { + es.close(); + sseRef.current = null; + }; + }, [handleNewMessage]); + + return {filteredLogs: filtered, distinctNames}; +} diff --git a/src/components/ScrollIntoView.tsx b/src/components/ScrollIntoView.tsx new file mode 100644 index 0000000..bcbc7d4 --- /dev/null +++ b/src/components/ScrollIntoView.tsx @@ -0,0 +1,14 @@ +import {useEffect, useRef} from "react"; + +/** + * An element that always scrolls into view when it is rendered. When added to a list, the entire list will scroll to show this element. + */ +export default function ScrollIntoView() { + const elementRef = useRef(null); + + useEffect(() => { + if (elementRef.current) elementRef.current.scrollIntoView({ behavior: "smooth" }); + }); + + return
; +} diff --git a/src/index.css b/src/index.css index 4d39cfb..986e666 100644 --- a/src/index.css +++ b/src/index.css @@ -7,13 +7,15 @@ color: rgba(255, 255, 255, 0.87); background-color: #242424; + --accent-color: #008080; + font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -html, body { +html, body, #root { margin: 0; padding: 0; @@ -25,11 +27,7 @@ html, body { a { font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; + color: canvastext; } h1 { @@ -49,7 +47,7 @@ button { transition: border-color 0.25s; } button:hover { - border-color: #646cff; + border-color: var(--accent-color); } button:focus, button:focus-visible { @@ -60,9 +58,8 @@ button:focus-visible { :root { color: #213547; background-color: #ffffff; - } - a:hover { - color: #747bff; + + --accent-color: #00AAAA; } button { background-color: #f9f9f9; diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index c58d0f3..34a6ecc 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -1,19 +1,9 @@ /* editor UI */ -.outer-editor-container { - margin-inline: auto; - display: flex; - justify-self: center; - padding: 10px; - align-items: center; - width: 80vw; - height: 80vh; -} - .inner-editor-container { - outline-style: solid; - border-radius: 10pt; - width: 90%; + box-sizing: border-box; + margin: 1rem; + width: calc(100% - 2rem); height: 100%; } diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 8208a70..829bbfc 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -80,30 +80,28 @@ const VisProgUI = () => { } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore return ( -
-
- - - {/* contains the drag and drop panel for nodes */} - - - - -
+
+ + + {/* contains the drag and drop panel for nodes */} + + + +
); }; diff --git a/src/utils/cellStore.ts b/src/utils/cellStore.ts new file mode 100644 index 0000000..eb64907 --- /dev/null +++ b/src/utils/cellStore.ts @@ -0,0 +1,29 @@ +import {useSyncExternalStore} from "react"; + +type Unsub = () => void; + +export type Cell = { + get: () => T; + set: (next: T | ((prev: T) => T)) => void; + subscribe: (callback: () => void) => Unsub; +}; + +export function cell(initial: T): Cell { + let value = initial; + const listeners = new Set<() => void>(); + return { + get: () => value, + set: (next) => { + value = typeof next === "function" ? (next as (v: T) => T)(value) : next; + for (const l of listeners) l(); + }, + subscribe: (callback) => { + listeners.add(callback); + return () => listeners.delete(callback); + }, + }; +} + +export function useCell(c: Cell) { + return useSyncExternalStore(c.subscribe, c.get, c.get); +} diff --git a/src/utils/formatDuration.ts b/src/utils/formatDuration.ts new file mode 100644 index 0000000..2e9f88d --- /dev/null +++ b/src/utils/formatDuration.ts @@ -0,0 +1,21 @@ +/** + * Format a time duration like `HH:MM:SS.mmm`. + * + * @param durationMs time duration in milliseconds. + * @return formatted time string. + */ +export default function formatDuration(durationMs: number): string { + const isNegative = durationMs < 0; + if (isNegative) durationMs = -durationMs; + + const hours = Math.floor(durationMs / 3600000); + const minutes = Math.floor((durationMs % 3600000) / 60000); + const seconds = Math.floor((durationMs % 60000) / 1000); + const milliseconds = Math.floor(durationMs % 1000); + + return (isNegative ? '-' : '') + + `${hours.toString().padStart(2, '0')}:` + + `${minutes.toString().padStart(2, '0')}:` + + `${seconds.toString().padStart(2, '0')}.` + + `${milliseconds.toString().padStart(3, '0')}`; +} diff --git a/src/utils/priorityFiltering.ts b/src/utils/priorityFiltering.ts new file mode 100644 index 0000000..7638f34 --- /dev/null +++ b/src/utils/priorityFiltering.ts @@ -0,0 +1,24 @@ +export type PriorityFilterPredicate = { + priority: number; + predicate: (element: T) => boolean | null; // The predicate and its priority are ignored if it returns null. +} + +/** + * Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true. + * @param element The element to apply the predicates to. + * @param predicates The list of predicates to apply. + */ +export function applyPriorityPredicates(element: T, predicates: PriorityFilterPredicate[]): boolean { + let highestPriority = -1; + let highestKeep = true; + for (const predicate of predicates) { + if (predicate.priority >= highestPriority) { + const predicateKeep = predicate.predicate(element); + if (predicateKeep === null) continue; // This predicate doesn't care about the element, so skip it + if (predicate.priority > highestPriority) highestKeep = true; + highestPriority = predicate.priority; + highestKeep = highestKeep && predicateKeep; + } + } + return highestKeep; +} diff --git a/test/components/Logging/Filters.test.tsx b/test/components/Logging/Filters.test.tsx new file mode 100644 index 0000000..9d5e40b --- /dev/null +++ b/test/components/Logging/Filters.test.tsx @@ -0,0 +1,328 @@ +import {render, screen, waitFor, fireEvent} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as React from "react"; + +type ControlledUseState = typeof React.useState & { + __forceNextReturn?: (value: any) => jest.Mock; + __resetMockState?: () => void; +}; + +jest.mock("react", () => { + const actual = jest.requireActual("react"); + const queue: Array<{value: any; setter: jest.Mock}> = []; + const mockUseState = ((initial: any) => { + if (queue.length) { + const {value, setter} = queue.shift()!; + return [value, setter]; + } + return actual.useState(initial); + }) as ControlledUseState; + + mockUseState.__forceNextReturn = (value: any) => { + const setter = jest.fn(); + queue.push({value, setter}); + return setter; + }; + mockUseState.__resetMockState = () => { + queue.length = 0; + }; + + return { + __esModule: true, + ...actual, + useState: mockUseState, + }; +}); +import Filters from "../../../src/components/Logging/Filters.tsx"; +import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts"; + +const GLOBAL = "global_log_level"; +const AGENT_PREFIX = "agent_log_level_"; +const optionMapping = new Map([ + ["ALL", 0], + ["DEBUG", 10], + ["INFO", 20], + ["WARNING", 30], + ["ERROR", 40], + ["CRITICAL", 50], + ["NONE", 999_999_999_999], +]); + +const controlledUseState = React.useState as ControlledUseState; + +afterEach(() => { + controlledUseState.__resetMockState?.(); +}); + +function getCallArg(mock: jest.Mock, index = 0): T { + return mock.mock.calls[index][0] as T; +} + +function sampleRecord(levelno: number, name = "any.logger"): LogRecord { + return { + levelname: "UNKNOWN", + levelno, + name, + message: "Whatever", + created: 0, + relativeCreated: 0, + firstCreated: 0, + firstRelativeCreated: 0, + }; +} + +// -------------------------------------------------------------------------- + +describe("Filters", () => { + describe("Global level filter", () => { + it("initializes to INFO when missing", async () => { + const setFilterPredicates = jest.fn(); + const filterPredicates = new Map(); + + const view = render( + ()} + /> + ); + + // Effect sets default to INFO + await waitFor(() => { + expect(setFilterPredicates).toHaveBeenCalled(); + }); + + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const newMap = updater(filterPredicates); + const global = newMap.get(GLOBAL)!; + + expect(global.value).toBe("INFO"); + expect(global.priority).toBe(0); + // Predicate gate at INFO (>= 20) + expect(global.predicate(sampleRecord(10))).toBe(false); + expect(global.predicate(sampleRecord(20))).toBe(true); + + // UI shows INFO selected after parent state updates + view.rerender( + ()} + /> + ); + + const globalSelect = screen.getByLabelText("Global:"); + expect((globalSelect as HTMLSelectElement).value).toBe("INFO"); + }); + + it("updates predicate when selecting a higher level", async () => { + // Start with INFO already present + const existing = new Map([ + [ + GLOBAL, + { + value: "INFO", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("INFO")! + } + ] + ]); + + const setFilterPredicates = jest.fn(); + const user = userEvent.setup(); + + render( + ()} + /> + ); + + const select = screen.getByLabelText("Global:"); + await user.selectOptions(select, "ERROR"); + + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const updated = updater(existing); + const global = updated.get(GLOBAL)!; + + expect(global.value).toBe("ERROR"); + expect(global.priority).toBe(0); + expect(global.predicate(sampleRecord(30))).toBe(false); + expect(global.predicate(sampleRecord(40))).toBe(true); + }); + }); + + describe("Agent level filters", () => { + it("adds an agent using the current global level when none specified", async () => { + // Global set to WARNING + const existing = new Map([ + [ + GLOBAL, + { + value: "WARNING", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("WARNING")! + } + ] + ]); + + const setFilterPredicates = jest.fn(); + const user = userEvent.setup(); + + render( + (["pepper.speech", "vision.agent"])} + /> + ); + + const addSelect = screen.getByLabelText("Add:"); + await user.selectOptions(addSelect, "pepper.speech"); + + // Agent setter is functional: prev => next + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const next = updater(existing); + + const key = AGENT_PREFIX + "pepper.speech"; + const agentPred = next.get(key)!; + + expect(agentPred.priority).toBe(1); + expect(agentPred.value).toEqual({agentName: "pepper.speech", level: "WARNING"}); + // When agentName matches, enforce WARNING (>= 30) + expect(agentPred.predicate(sampleRecord(20, "pepper.speech"))).toBe(false); + expect(agentPred.predicate(sampleRecord(30, "pepper.speech"))).toBe(true); + // Other agents -> null + expect(agentPred.predicate(sampleRecord(999, "other"))).toBeNull(); + }); + + it("changes an agent's level when its select is updated", async () => { + // Prepopulate agent predicate at WARNING + const key = AGENT_PREFIX + "pepper.speech"; + const existing = new Map([ + [ + GLOBAL, + { + value: "INFO", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("INFO")! + } + ], + [ + key, + { + value: {agentName: "pepper.speech", level: "WARNING"}, + priority: 1, + predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("WARNING")! : null) + } + ] + ]); + + const setFilterPredicates = jest.fn(); + const user = userEvent.setup(); + + const element = render( + + ); + + const agentSelect = element.container.querySelector("select#log_level_pepper\\.speech")!; + + await user.selectOptions(agentSelect, "ERROR"); + + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const next = updater(existing); + const updated = next.get(key)!; + + expect(updated.value).toEqual({agentName: "pepper.speech", level: "ERROR"}); + // Threshold moved to ERROR (>= 40) + expect(updated.predicate(sampleRecord(30, "pepper.speech"))).toBe(false); + expect(updated.predicate(sampleRecord(40, "pepper.speech"))).toBe(true); + }); + + it("deletes an agent predicate when clicking its name button", async () => { + const key = AGENT_PREFIX + "pepper.speech"; + const existing = new Map([ + [ + GLOBAL, + { + value: "INFO", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("INFO")! + } + ], + [ + key, + { + value: {agentName: "pepper.speech", level: "INFO"}, + priority: 1, + predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("INFO")! : null) + } + ] + ]); + + const setFilterPredicates = jest.fn(); + const user = userEvent.setup(); + + render( + (["pepper.speech"])} + /> + ); + + const deleteBtn = screen.getByRole("button", {name: "speech:"}); + await user.click(deleteBtn); + + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const next = updater(existing); + expect(next.has(key)).toBe(false); + }); + }); + + describe("Filter popup behavior", () => { + function renderWithPopupOpen() { + const existing = new Map([ + [ + GLOBAL, + { + value: "INFO", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("INFO")! + } + ] + ]); + const setFilterPredicates = jest.fn(); + const forceNext = controlledUseState.__forceNextReturn; + if (!forceNext) throw new Error("useState mock missing helper"); + const setOpen = forceNext(true); + + render( + + ); + + return { setOpen }; + } + + it("closes the popup when clicking outside", () => { + const { setOpen } = renderWithPopupOpen(); + fireEvent.mouseDown(document.body); + expect(setOpen).toHaveBeenCalledWith(false); + }); + + it("closes the popup when pressing Escape", () => { + const { setOpen } = renderWithPopupOpen(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(setOpen).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/test/components/Logging/Logging.test.tsx b/test/components/Logging/Logging.test.tsx new file mode 100644 index 0000000..03d4a92 --- /dev/null +++ b/test/components/Logging/Logging.test.tsx @@ -0,0 +1,239 @@ +import {render, screen, fireEvent, act, waitFor} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import type {Cell} from "../../../src/utils/cellStore.ts"; +import {cell} from "../../../src/utils/cellStore.ts"; +import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts"; + +const mockFiltersRender = jest.fn(); +const loggingStoreRef: { current: null | { setState: (state: Partial) => void } } = { current: null }; + +type LoggingSettingsState = { + showRelativeTime: boolean; + setShowRelativeTime: (show: boolean) => void; + scrollToBottom: boolean; + setScrollToBottom: (scroll: boolean) => void; +}; + +jest.mock("zustand", () => { + const actual = jest.requireActual("zustand"); + const actualCreate = actual.create; + return { + __esModule: true, + ...actual, + create: (...args: any[]) => { + const store = actualCreate(...args); + const state = store.getState(); + if ("setShowRelativeTime" in state && "setScrollToBottom" in state) { + loggingStoreRef.current = store; + } + return store; + }, + }; +}); + +jest.mock("../../../src/components/Logging/Filters.tsx", () => { + const React = jest.requireActual("react"); + return { + __esModule: true, + default: (props: any) => { + mockFiltersRender(props); + return React.createElement("div", {"data-testid": "filters-mock"}, "filters"); + }, + }; +}); + +jest.mock("../../../src/components/Logging/useLogs.ts", () => { + const actual = jest.requireActual("../../../src/components/Logging/useLogs.ts"); + return { + __esModule: true, + ...actual, + useLogs: jest.fn(), + }; +}); + +import {useLogs} from "../../../src/components/Logging/useLogs.ts"; +const mockUseLogs = useLogs as jest.MockedFunction; + +type LoggingComponent = typeof import("../../../src/components/Logging/Logging.tsx").default; +let Logging: LoggingComponent; + +beforeAll(async () => { + if (!Element.prototype.scrollIntoView) { + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + writable: true, + value: function () {}, + }); + } + + ({default: Logging} = await import("../../../src/components/Logging/Logging.tsx")); +}); + +beforeEach(() => { + mockUseLogs.mockReset(); + mockFiltersRender.mockReset(); + mockUseLogs.mockReturnValue({filteredLogs: [], distinctNames: new Set()}); + resetLoggingStore(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function resetLoggingStore() { + loggingStoreRef.current?.setState({ + showRelativeTime: false, + scrollToBottom: true, + }); +} + +function makeRecord(overrides: Partial = {}): LogRecord { + return { + name: "pepper.logger", + message: "default", + levelname: "INFO", + levelno: 20, + created: 1, + relativeCreated: 1, + firstCreated: 1, + firstRelativeCreated: 1, + ...overrides, + }; +} + +function makeCell(overrides: Partial = {}): Cell { + return cell(makeRecord(overrides)); +} + +describe("Logging component", () => { + it("renders log messages and toggles the timestamp between absolute and relative view", async () => { + const logCell = makeCell({ + name: "pepper.trace.logging", + message: "Ping", + levelname: "WARNING", + levelno: 30, + created: 1_700_000_000, + relativeCreated: 12_345, + firstCreated: 1_700_000_000, + firstRelativeCreated: 12_345, + }); + + const names = new Set(["pepper.trace.logging"]); + mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: names}); + + jest.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("ABS TIME"); + const user = userEvent.setup(); + + render(); + + expect(screen.getByText("Logs")).toBeInTheDocument(); + expect(screen.getByText("WARNING")).toBeInTheDocument(); + expect(screen.getByText("logging")).toBeInTheDocument(); + expect(screen.getByText("Ping")).toBeInTheDocument(); + + let timestamp = screen.queryByText("ABS TIME"); + if (!timestamp) { + // if previous test left the store toggled, click once to show absolute time + timestamp = screen.getByText("00:00:12.345"); + await user.click(timestamp); + timestamp = screen.getByText("ABS TIME"); + } + + await user.click(timestamp); + expect(screen.getByText("00:00:12.345")).toBeInTheDocument(); + }); + + it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => { + const logs = [ + makeCell({message: "first", firstRelativeCreated: 1}), + makeCell({message: "second", firstRelativeCreated: 2}), + ]; + mockUseLogs.mockReturnValue({filteredLogs: logs, distinctNames: new Set()}); + + const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {}); + const user = userEvent.setup(); + const view = render(); + + expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull(); + + const scrollable = view.container.querySelector(".scroll-y"); + expect(scrollable).toBeTruthy(); + + fireEvent.wheel(scrollable!); + + const button = await screen.findByRole("button", {name: "Scroll to bottom"}); + await user.click(button); + + expect(scrollSpy).toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull(); + }); + }); + + it("scrolls the last element into view when a log cell updates", async () => { + const logCell = makeCell({message: "Initial", firstRelativeCreated: 42}); + mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: new Set()}); + + const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {}); + render(); + + await waitFor(() => { + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + scrollSpy.mockClear(); + + act(() => { + const current = logCell.get(); + logCell.set({...current, message: "Updated"}); + }); + + expect(screen.getByText("Updated")).toBeInTheDocument(); + await waitFor(() => { + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("passes filter state to Filters and re-invokes useLogs when predicates change", async () => { + const distinct = new Set(["pepper.core"]); + mockUseLogs.mockImplementation((_filters: Map) => ({ + filteredLogs: [], + distinctNames: distinct, + })); + + render(); + + expect(mockFiltersRender).toHaveBeenCalledTimes(1); + const firstProps = mockFiltersRender.mock.calls[0][0]; + expect(firstProps.agentNames).toBe(distinct); + + const initialMap = firstProps.filterPredicates; + expect(initialMap).toBeInstanceOf(Map); + expect(initialMap.size).toBe(0); + expect(mockUseLogs).toHaveBeenCalledWith(initialMap); + + const updatedPredicate: LogFilterPredicate = { + value: "custom", + priority: 0, + predicate: () => true, + }; + + act(() => { + firstProps.setFilterPredicates((prev: Map) => { + const next = new Map(prev); + next.set("custom", updatedPredicate); + return next; + }); + }); + + await waitFor(() => { + expect(mockUseLogs).toHaveBeenCalledTimes(2); + }); + + const nextFilters = mockUseLogs.mock.calls[1][0]; + expect(nextFilters.get("custom")).toBe(updatedPredicate); + + const secondProps = mockFiltersRender.mock.calls[mockFiltersRender.mock.calls.length - 1][0]; + expect(secondProps.filterPredicates).toBe(nextFilters); + }); +}); diff --git a/test/components/Logging/useLogs.test.tsx b/test/components/Logging/useLogs.test.tsx new file mode 100644 index 0000000..30a7c2d --- /dev/null +++ b/test/components/Logging/useLogs.test.tsx @@ -0,0 +1,246 @@ +import { render, screen, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import {type LogRecord, useLogs} from "../../../src/components/Logging/useLogs.ts"; +import {type cell, useCell} from "../../../src/utils/cellStore.ts"; +import { StrictMode } from "react"; + +jest.mock("../../../src/utils/priorityFiltering.ts", () => ({ + applyPriorityPredicates: jest.fn((_log, preds: any[]) => + preds.every(() => true) // default: pass all + ), +})); +import {applyPriorityPredicates} from "../../../src/utils/priorityFiltering.ts"; + +class MockEventSource { + url: string; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + close = jest.fn(); + + constructor(url: string) { + this.url = url; + // expose the latest instance for tests: + (globalThis as any).__es = this; + } +} + +beforeAll(() => { + globalThis.EventSource = MockEventSource as any; +}); + +afterEach(() => { + // reset mock so previous instance not reused accidentally + (globalThis as any).__es = undefined; + jest.clearAllMocks(); +}); + +function LogsProbe({ filters }: { filters: Map }) { + const { filteredLogs, distinctNames } = useLogs(filters); + + return ( +
+
{distinctNames.size}
+
    + {filteredLogs.map((c, i) => ( + + ))} +
+
+ ); +} + +function LogItem({ cell: c, index }: { cell: ReturnType>; index: number }) { + const value = useCell(c); + return ( +
  • + {value.name} + {value.message} + {String(value.firstCreated)} + {String(value.created)} + {value.reference ?? ""} +
  • + ); +} + +function emit(log: LogRecord) { + const eventSource = (globalThis as any).__es as MockEventSource; + if (!eventSource || !eventSource.onmessage) throw new Error("EventSource not initialized"); + act(() => { + eventSource.onmessage!({ data: JSON.stringify(log) }); + }); +} + +describe("useLogs (unit)", () => { + it("creates EventSource once and closes on unmount", () => { + const filters = new Map(); // allow all by default + const { unmount } = render( + + + + ); + const es = (globalThis as any).__es as MockEventSource; + expect(es).toBeTruthy(); + expect(es.url).toBe("http://localhost:8000/logs/stream"); + + unmount(); + expect(es.close).toHaveBeenCalledTimes(1); + }); + + it("appends filtered logs and collects distinct names", () => { + const filters = new Map(); + render( + + + + ); + + expect(screen.getByTestId("names-count")).toHaveTextContent("0"); + + emit({ + levelname: "DEBUG", + levelno: 10, + name: "alpha", + message: "m1", + created: 1, + relativeCreated: 1, + firstCreated: 1, + firstRelativeCreated: 1, + }); + emit({ + levelname: "DEBUG", + levelno: 10, + name: "beta", + message: "m2", + created: 2, + relativeCreated: 2, + firstCreated: 2, + firstRelativeCreated: 2, + }); + emit({ + levelname: "DEBUG", + levelno: 10, + name: "alpha", + message: "m3", + created: 3, + relativeCreated: 3, + firstCreated: 3, + firstRelativeCreated: 3, + }); + + // 3 messages (no reference), 2 distinct names + expect(screen.getAllByRole("listitem")).toHaveLength(3); + expect(screen.getByTestId("names-count")).toHaveTextContent("2"); + + expect(screen.getByTestId("log-0-name")).toHaveTextContent("alpha"); + expect(screen.getByTestId("log-1-name")).toHaveTextContent("beta"); + expect(screen.getByTestId("log-2-name")).toHaveTextContent("alpha"); + }); + + it("updates first message with reference when a second one with that reference comes", () => { + const filters = new Map(); + render(); + + // First message with ref r1 + emit({ + levelname: "DEBUG", + levelno: 10, + name: "svc", + message: "first", + reference: "r1", + created: 10, + relativeCreated: 10, + firstCreated: 10, + firstRelativeCreated: 10, + }); + + // Second message with same ref r1, should still be a single item + emit({ + levelname: "DEBUG", + levelno: 10, + name: "svc", + message: "second", + reference: "r1", + created: 20, + relativeCreated: 20, + firstCreated: 20, + firstRelativeCreated: 20, + }); + + const items = screen.getAllByRole("listitem"); + expect(items).toHaveLength(1); + + // Same single item, but message should be "second" + expect(screen.getByTestId("log-0-msg")).toHaveTextContent("second"); + // The "firstCreated" should remain the original (10), while "created" is now 20 + expect(screen.getByTestId("log-0-first")).toHaveTextContent("10"); + expect(screen.getByTestId("log-0-created")).toHaveTextContent("20"); + expect(screen.getByTestId("log-0-ref")).toHaveTextContent("r1"); + }); + + it("runs recomputeFiltered when filters change", () => { + const allowAll = new Map(); + const { rerender } = render(); + + emit({ + levelname: "DEBUG", + levelno: 10, + name: "n1", + message: "ok", + created: 1, + relativeCreated: 1, + firstCreated: 1, + firstRelativeCreated: 1, + }); + emit({ + levelname: "DEBUG", + levelno: 10, + name: "n2", + message: "ok", + created: 2, + relativeCreated: 2, + firstCreated: 2, + firstRelativeCreated: 2, + }); + emit({ + levelname: "INFO", + levelno: 20, + name: "n3", + message: "ok1", + reference: "r1", + created: 3, + relativeCreated: 3, + firstCreated: 3, + firstRelativeCreated: 3, + }); + emit({ + levelname: "INFO", + levelno: 20, + name: "n3", + message: "ok2", + reference: "r1", + created: 4, + relativeCreated: 4, + firstCreated: 4, + firstRelativeCreated: 4, + }); + + expect(screen.getAllByRole("listitem")).toHaveLength(3); + + // Now change filters to block all < INFO + (applyPriorityPredicates as jest.Mock).mockImplementation((l) => l.levelno >= 20); + const blockDebug = new Map([["dummy", { value: true }]]); + rerender(); + + // Should recompute with shorter list + expect(screen.queryAllByRole("listitem")).toHaveLength(1); + + // Switch back to allow-all + (applyPriorityPredicates as jest.Mock).mockImplementation((_log, preds: any[]) => + preds.every(() => true) + ); + rerender(); + + // recompute should restore all three + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); +}); diff --git a/test/eslint.config.js.ts b/test/eslint.config.js.ts new file mode 100644 index 0000000..e69de29 diff --git a/test/utils/cellStore.test.tsx b/test/utils/cellStore.test.tsx new file mode 100644 index 0000000..96460b8 --- /dev/null +++ b/test/utils/cellStore.test.tsx @@ -0,0 +1,156 @@ +import {render, screen, act} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import {type Cell, cell, useCell} from "../../src/utils/cellStore.ts"; + +describe("cell store (unit)", () => { + it("returns initial value with get()", () => { + const c = cell(123); + expect(c.get()).toBe(123); + }); + + it("updates value with set(next)", () => { + const c = cell("a"); + c.set("b"); + expect(c.get()).toBe("b"); + }); + + it("gives previous value in set(updater)", () => { + const c = cell(1); + c.set((prev) => prev + 2); + expect(c.get()).toBe(3); + }); + + it("calls subscribe callback on set", () => { + const c = cell(0); + const cb = jest.fn(); + const unsub = c.subscribe(cb); + + c.set(1); + c.set(2); + + expect(cb).toHaveBeenCalledTimes(2); + unsub(); + }); + + it("stops notifications when unsubscribing", () => { + const c = cell(0); + const cb = jest.fn(); + const unsub = c.subscribe(cb); + + c.set(1); + unsub(); + c.set(2); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("updates multiple listeners", () => { + const c = cell("x"); + const a = jest.fn(); + const b = jest.fn(); + const ua = c.subscribe(a); + const ub = c.subscribe(b); + + c.set("y"); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + + ua(); + ub(); + }); +}); + +describe("cell store (integration)", () => { + function View({c, label}: { c: Cell; label: string }) { + const v = useCell(c); + // count renders to verify re-render behavior + (View as any).__renders = ((View as any).__renders ?? 0) + 1; + return
    {String(v)}
    ; + } + + it("reads initial value and updates on set", () => { + const c = cell("hello"); + + render(); + + expect(screen.getByTestId("value")).toHaveTextContent("hello"); + + act(() => { + c.set("world"); + }); + + expect(screen.getByTestId("value")).toHaveTextContent("world"); + }); + + it("triggers one re-render with set", () => { + const c = cell(1); + (View as any).__renders = 0; + + render(); + + const rendersAfterMount = (View as any).__renders; + + act(() => { + c.set((prev: number) => prev + 1); + }); + + // exactly one extra render from the update + expect((View as any).__renders).toBe(rendersAfterMount + 1); + expect(screen.getByTestId("num")).toHaveTextContent("2"); + }); + + it("unsubscribes on unmount (no errors on later sets)", () => { + const c = cell("a"); + + const {unmount} = render(); + + unmount(); + + // should not throw even though there was a subscriber + expect(() => + act(() => { + c.set("b"); + }) + ).not.toThrow(); + }); + + it("only re-renders components that use the cell", () => { + const a = cell("A"); + const b = cell("B"); + + let rendersA = 0; + let rendersB = 0; + + function A() { + const v = useCell(a); + rendersA++; + return
    {v}
    ; + } + + function B() { + const v = useCell(b); + rendersB++; + return
    {v}
    ; + } + + render( + <> + + + + ); + + const rendersAAfterMount = rendersA; + const rendersBAfterMount = rendersB; + + act(() => { + a.set("A2"); // only A should update + }); + + expect(screen.getByTestId("A")).toHaveTextContent("A2"); + expect(screen.getByTestId("B")).toHaveTextContent("B"); + + expect(rendersA).toBe(rendersAAfterMount + 1); + expect(rendersB).toBe(rendersBAfterMount); // unchanged + }); +}); diff --git a/test/utils/formatDuration.test.ts b/test/utils/formatDuration.test.ts new file mode 100644 index 0000000..b686a43 --- /dev/null +++ b/test/utils/formatDuration.test.ts @@ -0,0 +1,53 @@ +import formatDuration from "../../src/utils/formatDuration.ts"; + +describe("formatting durations (unit)", () => { + it("does one millisecond", () => { + const result = formatDuration(1); + expect(result).toBe("00:00:00.001"); + }); + + it("does one-hundred twenty-three milliseconds", () => { + const result = formatDuration(123); + expect(result).toBe("00:00:00.123"); + }); + + it("does one second", () => { + const result = formatDuration(1*1000); + expect(result).toBe("00:00:01.000"); + }); + + it("does thirteen seconds", () => { + const result = formatDuration(13*1000); + expect(result).toBe("00:00:13.000"); + }); + + it("does one minute", () => { + const result = formatDuration(60*1000); + expect(result).toBe("00:01:00.000"); + }); + + it("does thirteen minutes", () => { + const result = formatDuration(13*60*1000); + expect(result).toBe("00:13:00.000"); + }); + + it("does one hour", () => { + const result = formatDuration(60*60*1000); + expect(result).toBe("01:00:00.000"); + }); + + it("does thirteen hours", () => { + const result = formatDuration(13*60*60*1000); + expect(result).toBe("13:00:00.000"); + }); + + it("does negative one millisecond", () => { + const result = formatDuration(-1); + expect(result).toBe("-00:00:00.001"); + }); + + it("does large negative durations", () => { + const result = formatDuration(-(123*60*60*1000 + 59*60*1000 + 59*1000 + 123)); + expect(result).toBe("-123:59:59.123"); + }); +}); diff --git a/test/utils/priorityFiltering.test.ts b/test/utils/priorityFiltering.test.ts new file mode 100644 index 0000000..6cc8789 --- /dev/null +++ b/test/utils/priorityFiltering.test.ts @@ -0,0 +1,81 @@ +import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../src/utils/priorityFiltering"; + +const makePred = (priority: number, fn: (el: T) => boolean | null): PriorityFilterPredicate => ({ + priority, + predicate: jest.fn(fn), +}); + +describe("applyPriorityPredicates (unit)", () => { + beforeEach(() => jest.clearAllMocks()); + + it("returns true when there are no predicates", () => { + expect(applyPriorityPredicates(123, [])).toBe(true); + }); + + it("behaves like a normal predicate with only one predicate", () => { + const even = makePred(1, (n) => n % 2 === 0); + expect(applyPriorityPredicates(2, [even])).toBe(true); + expect(applyPriorityPredicates(3, [even])).toBe(false); + }); + + it("determines the result only listening to the highest priority predicates", () => { + const lowFail = makePred(1, (_) => false); + const lowPass = makePred(1, (_) => true); + const highPass = makePred(10, (n) => n > 0); + const highFail = makePred(10, (n) => n < 0); + + expect(applyPriorityPredicates(5, [lowFail, highPass])).toBe(true); + expect(applyPriorityPredicates(5, [lowPass, highFail])).toBe(false); + }); + + it("uses all predicates at the highest priority", () => { + const high1 = makePred(5, (n) => n % 2 === 0); + const high2 = makePred(5, (n) => n > 2); + expect(applyPriorityPredicates(4, [high1, high2])).toBe(true); + expect(applyPriorityPredicates(2, [high1, high2])).toBe(false); + }); + + it("is order independent (later higher positive clears earlier lower negative)", () => { + const lowFalse = makePred(1, (_) => false); + const highTrue = makePred(9, (n) => n === 7); + + // Higher priority appears later → should reset and decide by highest only + expect(applyPriorityPredicates(7, [lowFalse, highTrue])).toBe(true); + + // Same set, different order → same result + expect(applyPriorityPredicates(7, [highTrue, lowFalse])).toBe(true); + }); + + it("handles many priorities: only max matters", () => { + const p1 = makePred(1, (_) => false); + const p3 = makePred(3, (_) => false); + const p5 = makePred(5, (n) => n > 0); + expect(applyPriorityPredicates(1, [p1, p3, p5])).toBe(true); + }); + + it("skips predicates that return null", () => { + const high = makePred(10, (n) => n === 0 ? true : null); + const low = makePred(1, (_) => false); + expect(applyPriorityPredicates(0, [high, low])).toBe(true); + expect(applyPriorityPredicates(1, [high, low])).toBe(false); + }); +}); + +describe("(integration) filter with applyPriorityPredicates", () => { + it("filters an array using only highest-priority predicates", () => { + const elems = [1, 2, 3, 4, 5]; + const low = makePred(0, (_) => false); + const high1 = makePred(5, (n) => n % 2 === 0); + const high2 = makePred(5, (n) => n > 2); + const result = elems.filter((e) => applyPriorityPredicates(e, [low, high1, high2])); + expect(result).toEqual([4]); + }); + + it("filters an array using only highest-priority predicates", () => { + const elems = [1, 2, 3, 4, 5]; + const low = makePred(0, (_) => false); + const high = makePred(5, (n) => n === 3 ? true : null); + const result = elems.filter((e) => applyPriorityPredicates(e, [low, high])); + expect(result).toEqual([3]); + }); +});