Merging dev into main #49
@@ -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",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
90
src/App.css
90
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?? */
|
||||
}
|
||||
@@ -104,6 +109,10 @@ main {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -121,6 +130,14 @@ main {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.min-height-0 {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scroll-y {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -141,6 +158,10 @@ main {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.margin-0 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.padding-sm {
|
||||
padding: .25rem;
|
||||
}
|
||||
@@ -150,7 +171,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 +192,59 @@ main {
|
||||
}
|
||||
.round-lg {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
29
src/App.tsx
29
src/App.tsx
@@ -5,25 +5,32 @@ import Home from './pages/Home/Home.tsx'
|
||||
import Robot from './pages/Robot/Robot.tsx';
|
||||
import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.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 (
|
||||
<div>
|
||||
<>
|
||||
<header>
|
||||
<Link to={"/"}>Home</Link>
|
||||
<button onClick={() => setShowLogs(!showLogs)}>Toggle Logging</button>
|
||||
</header>
|
||||
<main className={"flex-col align-center"}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/template" element={<TemplatePage />} />
|
||||
<Route path="/editor" element={<VisProg />} />
|
||||
<Route path="/robot" element={<Robot />} />
|
||||
<Route path="/ConnectedRobots" element={<ConnectedRobots />} />
|
||||
<div className={"flex-row justify-center flex-1 min-height-0"}>
|
||||
<main className={"flex-col align-center flex-1 scroll-y"}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/template" element={<TemplatePage />} />
|
||||
<Route path="/editor" element={<VisProg />} />
|
||||
<Route path="/robot" element={<Robot />} />
|
||||
<Route path="/ConnectedRobots" element={<ConnectedRobots />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
</main>
|
||||
{showLogs && <Logging />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
34
src/components/Logging/Filters.module.css
Normal file
34
src/components/Logging/Filters.module.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
200
src/components/Logging/Filters.tsx
Normal file
200
src/components/Logging/Filters.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
|
||||
import type {LogFilterPredicate} from "./useLogs.ts";
|
||||
|
||||
import styles from "./Filters.module.css";
|
||||
|
||||
type Setter<T> = (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 <div className={"flex-row gap-sm align-center"}>
|
||||
<label
|
||||
htmlFor={`log_level_${name}`}
|
||||
className={"font-small"}
|
||||
>
|
||||
{onDelete
|
||||
? <button
|
||||
className={`no-button ${styles.deletable}`}
|
||||
onClick={onDelete}
|
||||
>{normalizedName}:</button>
|
||||
: normalizedName + ':'
|
||||
}
|
||||
</label>
|
||||
<select
|
||||
id={`log_level_${name}`}
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
>
|
||||
{Array.from(optionMapping.keys()).map((key) => (
|
||||
<option key={key} value={key}>{key}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
|
||||
const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level";
|
||||
|
||||
function GlobalLevelFilter({
|
||||
filterPredicates,
|
||||
setFilterPredicates,
|
||||
}: {
|
||||
filterPredicates: Map<string, LogFilterPredicate>;
|
||||
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||
}) {
|
||||
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 <LevelPredicateElement
|
||||
name={"Global"}
|
||||
level={selected}
|
||||
setLevel={setSelected}
|
||||
/>;
|
||||
}
|
||||
|
||||
const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_";
|
||||
|
||||
function AgentLevelFilters({
|
||||
filterPredicates,
|
||||
setFilterPredicates,
|
||||
agentNames,
|
||||
}: {
|
||||
filterPredicates: Map<string, LogFilterPredicate>;
|
||||
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||
agentNames: Set<string>;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(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 <LevelPredicateElement
|
||||
key={key}
|
||||
name={agentName}
|
||||
level={level}
|
||||
setLevel={(level) => setAgentPredicate(agentName, level)}
|
||||
onDelete={() => deleteAgentPredicate(agentName)}
|
||||
/>;
|
||||
})}
|
||||
<div className={"flex-row gap-sm align-center"}>
|
||||
<label htmlFor={"add_agent"} className={"font-small"}>Add:</label>
|
||||
<select
|
||||
id={"add_agent"}
|
||||
value={""}
|
||||
onChange={(e) => !!e.target.value && setAgentPredicate(e.target.value)}
|
||||
>
|
||||
{["", ...agentNames.keys()].map((key) => (
|
||||
<option key={key} value={key}>{key.split(".").pop()}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function Filters({
|
||||
filterPredicates,
|
||||
setFilterPredicates,
|
||||
agentNames,
|
||||
}: {
|
||||
filterPredicates: Map<string, LogFilterPredicate>;
|
||||
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||
agentNames: Set<string>;
|
||||
}) {
|
||||
return <div className={"flex-1 flex-row flex-wrap gap-md align-center"}>
|
||||
<GlobalLevelFilter filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} />
|
||||
<AgentLevelFilters filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} agentNames={agentNames} />
|
||||
</div>;
|
||||
}
|
||||
39
src/components/Logging/Logging.module.css
Normal file
39
src/components/Logging/Logging.module.css
Normal file
@@ -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);
|
||||
}
|
||||
129
src/components/Logging/Logging.tsx
Normal file
129
src/components/Logging/Logging.tsx
Normal file
@@ -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<LoggingSettings>((set) => ({
|
||||
showRelativeTime: false,
|
||||
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
|
||||
scrollToBottom: true,
|
||||
setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
|
||||
}));
|
||||
|
||||
function LogMessage({
|
||||
recordCell,
|
||||
onUpdate,
|
||||
}: {
|
||||
recordCell: Cell<LogRecord>,
|
||||
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 <div className={`${styles.logContainer} round-md border-lg flex-row gap-md`}>
|
||||
<div className={`${styles[`accented${normalizedLevelNo}`]} flex-col padding-sm justify-between`}>
|
||||
<span className={"mono bold"}>{record.levelname}</span>
|
||||
<span className={"mono clickable font-small"}
|
||||
onClick={() => setShowRelativeTime(!showRelativeTime)}
|
||||
>{showRelativeTime
|
||||
? formatDuration(record.relativeCreated)
|
||||
: new Date(record.created * 1000).toLocaleTimeString()
|
||||
}</span>
|
||||
</div>
|
||||
<div className={"flex-col flex-1 padding-sm"}>
|
||||
<span className={"mono"}>{normalizedName}</span>
|
||||
<span>{record.message}</span>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const lastElementRef = useRef<HTMLLIElement>(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 <div ref={scrollableRef} className={"min-height-0 scroll-y padding-b-md"}>
|
||||
<ol className={`${styles.noNumbers} margin-0 flex-col gap-md`}>
|
||||
{recordCells.map((recordCell, i) => (
|
||||
<li key={`${i}_${recordCell.get().firstRelativeCreated}`}>
|
||||
<LogMessage recordCell={recordCell} onUpdate={scrollLastElementIntoView} />
|
||||
</li>
|
||||
))}
|
||||
<li ref={lastElementRef}></li>
|
||||
</ol>
|
||||
{!scrollToBottom && <button
|
||||
className={styles.floatingButton}
|
||||
onClick={() => {
|
||||
setScrollToBottom(true);
|
||||
scrollLastElementIntoView(true);
|
||||
}}
|
||||
>
|
||||
Scroll to bottom
|
||||
</button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function Logging() {
|
||||
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>());
|
||||
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
|
||||
|
||||
return <div className={`flex-col gap-lg min-height-0 ${styles.loggingContainer}`}>
|
||||
<div className={"flex-row gap-lg justify-between align-center"}>
|
||||
<h2 className={"margin-0"}>Logs</h2>
|
||||
<Filters
|
||||
filterPredicates={filterPredicates}
|
||||
setFilterPredicates={setFilterPredicates}
|
||||
agentNames={distinctNames}
|
||||
/>
|
||||
</div>
|
||||
<LogMessages recordCells={filteredLogs} />
|
||||
</div>;
|
||||
}
|
||||
146
src/components/Logging/useLogs.ts
Normal file
146
src/components/Logging/useLogs.ts
Normal file
@@ -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<LogRecord> & { value: any };
|
||||
|
||||
export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||
const [distinctNames, setDistinctNames] = useState<Set<string>>(new Set());
|
||||
const [filtered, setFiltered] = useState<Cell<LogRecord>[]>([]);
|
||||
|
||||
const sseRef = useRef<EventSource | null>(null);
|
||||
const filtersRef = useRef(filterPredicates);
|
||||
const logsRef = useRef<LogRecord[]>([]);
|
||||
|
||||
/** Map to store the first message for each reference, instance can be updated to change contents. */
|
||||
const firstByRefRef = useRef<Map<string, Cell<LogRecord>>>(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<LogRecord>[] = [];
|
||||
firstByRefRef.current = new Map();
|
||||
|
||||
for (const message of logsRef.current) {
|
||||
const messageCell = cell<LogRecord>({
|
||||
...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<LogRecord>({
|
||||
...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};
|
||||
}
|
||||
14
src/components/ScrollIntoView.tsx
Normal file
14
src/components/ScrollIntoView.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (elementRef.current) elementRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
|
||||
return <div ref={elementRef} />;
|
||||
}
|
||||
27
src/components/TextField.module.css
Normal file
27
src/components/TextField.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.text-field {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5pt;
|
||||
padding: 4px 8px;
|
||||
outline: none;
|
||||
background-color: canvas;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.text-field.invalid {
|
||||
border-color: red;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.text-field:focus:not(.invalid) {
|
||||
border-color: color-mix(in srgb, canvas, #777 10%);
|
||||
}
|
||||
|
||||
.text-field:read-only {
|
||||
cursor: pointer;
|
||||
background-color: color-mix(in srgb, canvas, #777 5%);
|
||||
}
|
||||
|
||||
.text-field:read-only:hover:not(.invalid) {
|
||||
border-color: color-mix(in srgb, canvas, #777 10%);
|
||||
}
|
||||
101
src/components/TextField.tsx
Normal file
101
src/components/TextField.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {useState} from "react";
|
||||
import styles from "./TextField.module.css";
|
||||
|
||||
/**
|
||||
* A text input element in our own style that calls `setValue` at every keystroke.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.value - The value of the text input.
|
||||
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||
* @param {string} [props.id] - The ID of the text input.
|
||||
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||
*/
|
||||
export function RealtimeTextField({
|
||||
value = "",
|
||||
setValue,
|
||||
onCommit,
|
||||
placeholder,
|
||||
className,
|
||||
id,
|
||||
ariaLabel,
|
||||
invalid = false,
|
||||
} : {
|
||||
value: string,
|
||||
setValue: (value: string) => void,
|
||||
onCommit: () => void,
|
||||
placeholder?: string,
|
||||
className?: string,
|
||||
id?: string,
|
||||
ariaLabel?: string,
|
||||
invalid?: boolean,
|
||||
}) {
|
||||
const [readOnly, setReadOnly] = useState(true);
|
||||
|
||||
const updateData = () => {
|
||||
setReadOnly(true);
|
||||
onCommit();
|
||||
};
|
||||
|
||||
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
|
||||
|
||||
return <input
|
||||
type={"text"}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setReadOnly(false)}
|
||||
onBlur={updateData}
|
||||
onKeyDown={updateOnEnter}
|
||||
readOnly={readOnly}
|
||||
id={id}
|
||||
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes
|
||||
className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
|
||||
aria-label={ariaLabel}
|
||||
/>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A text input element in our own style that calls `setValue` once the user presses the enter key or clicks outside the input.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.value - The value of the text input.
|
||||
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||
* @param {string} [props.id] - The ID of the text input.
|
||||
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||
*/
|
||||
export function TextField({
|
||||
value = "",
|
||||
setValue,
|
||||
placeholder,
|
||||
className,
|
||||
id,
|
||||
ariaLabel,
|
||||
invalid = false,
|
||||
} : {
|
||||
value: string,
|
||||
setValue: (value: string) => void,
|
||||
placeholder?: string,
|
||||
className?: string,
|
||||
id?: string,
|
||||
ariaLabel?: string,
|
||||
invalid?: boolean,
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
|
||||
const onCommit = () => setValue(inputValue);
|
||||
|
||||
return <RealtimeTextField
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
setValue={setInputValue}
|
||||
onCommit={onCommit}
|
||||
id={id}
|
||||
className={className}
|
||||
ariaLabel={ariaLabel}
|
||||
invalid={invalid}
|
||||
/>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -81,6 +71,16 @@
|
||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||
}
|
||||
|
||||
.node-goal {
|
||||
outline: yellow solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem yellow);
|
||||
}
|
||||
|
||||
.node-trigger {
|
||||
outline: teal solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem teal);
|
||||
}
|
||||
|
||||
.node-phase {
|
||||
outline: dodgerblue solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
||||
@@ -112,6 +112,22 @@
|
||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||
}
|
||||
|
||||
.draggable-node-goal {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: yellow solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem yellow);
|
||||
}
|
||||
|
||||
.draggable-node-trigger {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: teal solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem teal);
|
||||
}
|
||||
|
||||
.draggable-node-phase {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
|
||||
@@ -13,13 +13,15 @@ import {
|
||||
StartNodeComponent,
|
||||
EndNodeComponent,
|
||||
PhaseNodeComponent,
|
||||
NormNodeComponent
|
||||
NormNodeComponent,
|
||||
GoalNodeComponent,
|
||||
} from './visualProgrammingUI/components/NodeDefinitions.tsx';
|
||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||
import graphReducer from "./visualProgrammingUI/GraphReducer.ts";
|
||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||
import styles from './VisProg.module.css'
|
||||
import TriggerNodeComponent from "./visualProgrammingUI/components/TriggerNodeComponent.tsx";
|
||||
|
||||
// --| config starting params for flow |--
|
||||
|
||||
@@ -30,7 +32,9 @@ const NODE_TYPES = {
|
||||
start: StartNodeComponent,
|
||||
end: EndNodeComponent,
|
||||
phase: PhaseNodeComponent,
|
||||
norm: NormNodeComponent
|
||||
norm: NormNodeComponent,
|
||||
goal: GoalNodeComponent,
|
||||
trigger: TriggerNodeComponent,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,30 +84,28 @@ const VisProgUI = () => {
|
||||
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
|
||||
|
||||
return (
|
||||
<div className={styles.outerEditorContainer}>
|
||||
<div className={styles.innerEditorContainer}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||
nodeTypes={NODE_TYPES}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
onReconnectEnd={onReconnectEnd}
|
||||
onConnect={onConnect}
|
||||
snapToGrid
|
||||
fitView
|
||||
proOptions={{hideAttribution: true}}
|
||||
>
|
||||
<Panel position="top-center" className={styles.dndPanel}>
|
||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||
</Panel>
|
||||
<Controls/>
|
||||
<Background/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<div className={`${styles.innerEditorContainer} round-lg border-lg`}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||
nodeTypes={NODE_TYPES}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
onReconnectEnd={onReconnectEnd}
|
||||
onConnect={onConnect}
|
||||
snapToGrid
|
||||
fitView
|
||||
proOptions={{hideAttribution: true}}
|
||||
>
|
||||
<Panel position="top-center" className={styles.dndPanel}>
|
||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||
</Panel>
|
||||
<Controls/>
|
||||
<Background/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -128,6 +130,7 @@ function VisualProgrammingUI() {
|
||||
function runProgram() {
|
||||
const program = graphReducer();
|
||||
console.log(program);
|
||||
console.log(JSON.stringify(program, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,14 +15,15 @@ import type {
|
||||
Phase,
|
||||
PhaseReducer,
|
||||
PreparedGraph,
|
||||
PreparedPhase
|
||||
PreparedPhase, Reduced, TriggerReducer
|
||||
} from "./GraphReducerTypes.ts";
|
||||
import type {
|
||||
AppNode,
|
||||
GoalNode,
|
||||
NormNode,
|
||||
PhaseNode
|
||||
PhaseNode, TriggerNode
|
||||
} from "./VisProgTypes.tsx";
|
||||
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||
|
||||
/**
|
||||
* Reduces the current graph inside the visual programming editor into a BehaviorProgram
|
||||
@@ -31,13 +32,15 @@ import type {
|
||||
* @param {PhaseReducer} phaseReducer
|
||||
* @param {NormReducer} normReducer
|
||||
* @param {GoalReducer} goalReducer
|
||||
* @param {TriggerReducer} triggerReducer
|
||||
* @returns {BehaviorProgram}
|
||||
*/
|
||||
export default function graphReducer(
|
||||
graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor,
|
||||
phaseReducer: PhaseReducer = defaultPhaseReducer,
|
||||
normReducer: NormReducer = defaultNormReducer,
|
||||
goalReducer: GoalReducer = defaultGoalReducer
|
||||
goalReducer: GoalReducer = defaultGoalReducer,
|
||||
triggerReducer: TriggerReducer = defaultTriggerReducer,
|
||||
) : BehaviorProgram {
|
||||
const nodes: AppNode[] = useFlowStore.getState().nodes;
|
||||
const edges: Edge[] = useFlowStore.getState().edges;
|
||||
@@ -47,7 +50,8 @@ export default function graphReducer(
|
||||
phaseReducer(
|
||||
preparedPhase,
|
||||
normReducer,
|
||||
goalReducer
|
||||
goalReducer,
|
||||
triggerReducer,
|
||||
));
|
||||
};
|
||||
|
||||
@@ -58,12 +62,14 @@ export default function graphReducer(
|
||||
* @param {PreparedPhase} phase
|
||||
* @param {NormReducer} normReducer
|
||||
* @param {GoalReducer} goalReducer
|
||||
* @param {TriggerReducer} triggerReducer
|
||||
* @returns {Phase}
|
||||
*/
|
||||
export function defaultPhaseReducer(
|
||||
phase: PreparedPhase,
|
||||
normReducer: NormReducer = defaultNormReducer,
|
||||
goalReducer: GoalReducer = defaultGoalReducer
|
||||
goalReducer: GoalReducer = defaultGoalReducer,
|
||||
triggerReducer: TriggerReducer = defaultTriggerReducer,
|
||||
) : Phase {
|
||||
return {
|
||||
id: phase.phaseNode.id,
|
||||
@@ -71,7 +77,8 @@ export function defaultPhaseReducer(
|
||||
nextPhaseId: phase.nextPhaseId,
|
||||
phaseData: {
|
||||
norms: phase.connectedNorms.map(normReducer),
|
||||
goals: phase.connectedGoals.map(goalReducer)
|
||||
goals: phase.connectedGoals.map(goalReducer),
|
||||
triggers: phase.connectedTriggers.map(triggerReducer),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,11 +89,12 @@ export function defaultPhaseReducer(
|
||||
* @param {GoalNode} node
|
||||
* @returns {GoalData}
|
||||
*/
|
||||
function defaultGoalReducer(node: GoalNode) : GoalData {
|
||||
function defaultGoalReducer(node: GoalNode) : Reduced<GoalData> {
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.data.label,
|
||||
value: node.data.value
|
||||
description: node.data.description,
|
||||
achieved: node.data.achieved,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +104,7 @@ function defaultGoalReducer(node: GoalNode) : GoalData {
|
||||
* @param {NormNode} node
|
||||
* @returns {NormData}
|
||||
*/
|
||||
function defaultNormReducer(node: NormNode) :NormData {
|
||||
function defaultNormReducer(node: NormNode) :Reduced<NormData> {
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.data.label,
|
||||
@@ -104,6 +112,13 @@ function defaultNormReducer(node: NormNode) :NormData {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultTriggerReducer(node: TriggerNode): Reduced<TriggerNodeProps> {
|
||||
return {
|
||||
id: node.id,
|
||||
...node.data,
|
||||
}
|
||||
}
|
||||
|
||||
// Graph preprocessing functions:
|
||||
|
||||
/**
|
||||
@@ -117,6 +132,7 @@ function defaultNormReducer(node: NormNode) :NormData {
|
||||
export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph {
|
||||
const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[];
|
||||
const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[];
|
||||
const triggers : TriggerNode[] = nodes.filter((node) => node.type === 'trigger') as TriggerNode[];
|
||||
const orderedPhases : OrderedPhases = orderPhases(nodes, edges);
|
||||
|
||||
return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => {
|
||||
@@ -125,7 +141,8 @@ export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : Prep
|
||||
phaseNode: phase,
|
||||
nextPhaseId: nextPhase as string,
|
||||
connectedNorms: getIncomers({id: phase.id}, norms,edges),
|
||||
connectedGoals: getIncomers({id: phase.id}, goals,edges)
|
||||
connectedGoals: getIncomers({id: phase.id}, goals,edges),
|
||||
connectedTriggers: getIncomers({id: phase.id}, triggers, edges),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -150,32 +167,29 @@ export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases {
|
||||
) : OrderedPhases => {
|
||||
// get the current phase and the next phases;
|
||||
const currentPhase = phases[currentIndex];
|
||||
const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges);
|
||||
const nextNodes = getOutgoers(currentPhase,nodes, edges);
|
||||
const nextPhaseNodes = getOutgoers(currentPhase, phaseNodes, edges);
|
||||
const nextNodes = getOutgoers(currentPhase, nodes, edges);
|
||||
|
||||
// handles adding of the next phase to the chain, and error handle if an invalid state is received
|
||||
if (nextPhaseNodes.length === 1 && nextNodes.length === 1) {
|
||||
connections.set(currentPhase.id, nextPhaseNodes[0].id);
|
||||
return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections});
|
||||
} else {
|
||||
// handle erroneous states
|
||||
if (nextNodes.length === 0){
|
||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`);
|
||||
} else {
|
||||
if (nextNodes.length > 1) {
|
||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`);
|
||||
} else {
|
||||
if (nextNodes[0].type === "end"){
|
||||
connections.set(currentPhase.id, "end");
|
||||
// returns the final output of the function
|
||||
return { phaseNodes: phases, connections: connections};
|
||||
} else {
|
||||
throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// handle erroneous states
|
||||
if (nextNodes.length === 0) {
|
||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`);
|
||||
}
|
||||
if (nextNodes.length > 1) {
|
||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`);
|
||||
}
|
||||
if (nextNodes[0].type === "end") {
|
||||
connections.set(currentPhase.id, "end");
|
||||
// returns the final output of the function
|
||||
return {phaseNodes: phases, connections: connections};
|
||||
}
|
||||
throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`);
|
||||
}
|
||||
|
||||
// initializes the Map describing the connections between phase nodes
|
||||
// we need this Map to make sure we preserve this information,
|
||||
// so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type {Edge} from "@xyflow/react";
|
||||
import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx";
|
||||
import type {AppNode, GoalNode, NormNode, PhaseNode, TriggerNode} from "./VisProgTypes.tsx";
|
||||
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||
|
||||
|
||||
export type Reduced<T> = { id: string } & T;
|
||||
|
||||
/**
|
||||
* defines how a norm is represented in the simplified behavior program
|
||||
*/
|
||||
export type NormData = {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
@@ -15,9 +17,9 @@ export type NormData = {
|
||||
* defines how a goal is represented in the simplified behavior program
|
||||
*/
|
||||
export type GoalData = {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
description: string;
|
||||
achieved: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,7 @@ export type GoalData = {
|
||||
export type PhaseData = {
|
||||
norms: NormData[];
|
||||
goals: GoalData[];
|
||||
triggers: TriggerNodeProps[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -55,12 +58,14 @@ export type BehaviorProgram = Phase[];
|
||||
|
||||
|
||||
|
||||
export type NormReducer = (node: NormNode) => NormData;
|
||||
export type GoalReducer = (node: GoalNode) => GoalData;
|
||||
export type NormReducer = (node: NormNode) => Reduced<NormData>;
|
||||
export type GoalReducer = (node: GoalNode) => Reduced<GoalData>;
|
||||
export type TriggerReducer = (node: TriggerNode) => Reduced<TriggerNodeProps>;
|
||||
export type PhaseReducer = (
|
||||
preparedPhase: PreparedPhase,
|
||||
normReducer: NormReducer,
|
||||
goalReducer: GoalReducer
|
||||
goalReducer: GoalReducer,
|
||||
triggerReducer: TriggerReducer,
|
||||
) => Phase;
|
||||
|
||||
/**
|
||||
@@ -90,6 +95,7 @@ export type PreparedPhase = {
|
||||
nextPhaseId: string;
|
||||
connectedNorms: NormNode[];
|
||||
connectedGoals: GoalNode[];
|
||||
connectedTriggers: TriggerNode[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,18 +6,21 @@ import {
|
||||
type OnConnect,
|
||||
type OnReconnect,
|
||||
} from '@xyflow/react';
|
||||
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||
|
||||
|
||||
type defaultNodeData = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type OurNode<T> = Node<T & defaultNodeData>;
|
||||
|
||||
export type StartNode = Node<defaultNodeData, 'start'>;
|
||||
export type EndNode = Node<defaultNodeData, 'end'>;
|
||||
export type GoalNode = Node<defaultNodeData & { value: string; }, 'goal'>;
|
||||
export type GoalNode = Node<defaultNodeData & { description: string; achieved: boolean; }, 'goal'>;
|
||||
export type NormNode = Node<defaultNodeData & { value: string; }, 'norm'>;
|
||||
export type PhaseNode = Node<defaultNodeData & { number: number; }, 'phase'>;
|
||||
|
||||
export type TriggerNode = OurNode<TriggerNodeProps>;
|
||||
|
||||
/**
|
||||
* a type meant to house different node types, currently not used
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'react';
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import styles from "../../VisProg.module.css"
|
||||
import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
|
||||
import type {AppNode, PhaseNode, NormNode, GoalNode, TriggerNode} from "../VisProgTypes.tsx";
|
||||
|
||||
|
||||
|
||||
@@ -106,10 +106,48 @@ export function addNode(nodeType: string, position: XYPosition) {
|
||||
id: `norm-${normNumber}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {label: `new norm node`, value: "Pepper should be formal"},
|
||||
data: {label: `new norm node`, value: ""},
|
||||
}
|
||||
return normNode;
|
||||
}
|
||||
case "goal":
|
||||
{
|
||||
const goalNodes= nds.filter((node) => node.type === 'goal');
|
||||
let goalNumber
|
||||
if (goalNodes.length > 0) {
|
||||
const finalGoalId : number = +(goalNodes[goalNodes.length - 1].id.split('-')[1]);
|
||||
goalNumber = finalGoalId + 1;
|
||||
} else {
|
||||
goalNumber = 1;
|
||||
}
|
||||
|
||||
const goalNode : GoalNode = {
|
||||
id: `goal-${goalNumber}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {label: `new goal node`, description: "", achieved: false},
|
||||
}
|
||||
return goalNode;
|
||||
}
|
||||
case "trigger":
|
||||
{
|
||||
const triggerNodes= nds.filter((node) => node.type === 'trigger');
|
||||
let triggerNumber
|
||||
if (triggerNodes.length > 0) {
|
||||
const finalGoalId : number = +(triggerNodes[triggerNodes.length - 1].id.split('-')[1]);
|
||||
triggerNumber = finalGoalId + 1;
|
||||
} else {
|
||||
triggerNumber = 1;
|
||||
}
|
||||
|
||||
const triggerNode : TriggerNode = {
|
||||
id: `trigger-${triggerNumber}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {label: `new trigger node`, type: "keywords", value: []},
|
||||
}
|
||||
return triggerNode;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Node ${nodeType} not found`);
|
||||
}
|
||||
@@ -161,6 +199,12 @@ export function DndToolbar() {
|
||||
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
|
||||
norm Node
|
||||
</DraggableNode>
|
||||
<DraggableNode className={styles.draggableNodeGoal} nodeType="goal" onDrop={handleNodeDrop}>
|
||||
goal Node
|
||||
</DraggableNode>
|
||||
<DraggableNode className={styles.draggableNodeTrigger} nodeType="trigger" onDrop={handleNodeDrop}>
|
||||
trigger Node
|
||||
</DraggableNode>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,8 +11,9 @@ import type {
|
||||
StartNode,
|
||||
EndNode,
|
||||
PhaseNode,
|
||||
NormNode
|
||||
NormNode, GoalNode
|
||||
} from "../VisProgTypes.tsx";
|
||||
import {TextField} from "../../../../components/TextField.tsx";
|
||||
|
||||
//Toolbar definitions
|
||||
|
||||
@@ -44,56 +45,6 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
||||
</NodeToolbar>);
|
||||
}
|
||||
|
||||
// Renaming component
|
||||
|
||||
/**
|
||||
* Adds a component that can be used to edit a node's label entry inside its Data
|
||||
* can be added to any custom node that has a label inside its Data
|
||||
*
|
||||
* @param {string} nodeLabel
|
||||
* @param {string} nodeId
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : string, nodeId: string}) {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const updateData = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const input = event.target.value;
|
||||
updateNodeData(nodeId, {label: input});
|
||||
event.currentTarget.setAttribute("readOnly", "true");
|
||||
window.getSelection()?.empty();
|
||||
event.currentTarget.classList.replace("nodrag", "drag"); // enable dragging of the node with cursor on the input box
|
||||
};
|
||||
|
||||
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
|
||||
|
||||
const enableEditing = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
if(event.currentTarget.hasAttribute("readOnly")) {
|
||||
event.currentTarget.removeAttribute("readOnly"); // enable editing
|
||||
event.currentTarget.select(); // select the text input
|
||||
window.getSelection()?.collapseToEnd(); // move the caret to the end of the current value
|
||||
event.currentTarget.classList.replace("drag", "nodrag"); // disable dragging using input box
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.NodeTextBar }>
|
||||
<label>name: </label>
|
||||
<input
|
||||
className={`drag ${styles.nodeTextInput}`} // prevents dragging the component when user has focused the text input
|
||||
type={"text"}
|
||||
defaultValue={nodeLabel}
|
||||
onKeyDown={updateOnEnter}
|
||||
onBlur={updateData}
|
||||
onClick={enableEditing}
|
||||
maxLength={25}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Definitions of Nodes
|
||||
|
||||
@@ -148,11 +99,25 @@ export const EndNodeComponent = ({id, data}: NodeProps<EndNode>) => {
|
||||
* @constructor
|
||||
*/
|
||||
export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const updateLabel = (value: string) => updateNodeData(id, {...data, label: value});
|
||||
|
||||
const label_input_id = `phase_${id}_label_input`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
||||
<EditableName nodeLabel={data.label} nodeId={id}/>
|
||||
<div className={"flex-row gap-sm"}>
|
||||
<label htmlFor={label_input_id}>Name:</label>
|
||||
<TextField
|
||||
id={label_input_id}
|
||||
value={data.label}
|
||||
setValue={updateLabel}
|
||||
placeholder={"Phase ..."}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} id="target"/>
|
||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||
<Handle type="source" position={Position.Right} id="source"/>
|
||||
@@ -167,17 +132,69 @@ export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {defaultNodeData & {value: string}} data
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||
<EditableName nodeLabel={data.label} nodeId={id}/>
|
||||
<Handle type="source" position={Position.Right} id="NormSource"/>
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const text_input_id = `norm_${id}_text_input`;
|
||||
|
||||
const setValue = (value: string) => {
|
||||
updateNodeData(id, {value: value});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||
<div className={"flex-row gap-sm"}>
|
||||
<label htmlFor={text_input_id}>Norm:</label>
|
||||
<TextField
|
||||
id={text_input_id}
|
||||
value={data.value}
|
||||
setValue={(val) => setValue(val)}
|
||||
placeholder={"Pepper should ..."}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
<Handle type="source" position={Position.Right} id="NormSource"/>
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
export const GoalNodeComponent = ({id, data}: NodeProps<GoalNode>) => {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const text_input_id = `goal_${id}_text_input`;
|
||||
const checkbox_id = `goal_${id}_checkbox`;
|
||||
|
||||
const setDescription = (value: string) => {
|
||||
updateNodeData(id, {...data, description: value});
|
||||
}
|
||||
|
||||
const setAchieved = (value: boolean) => {
|
||||
updateNodeData(id, {...data, achieved: value});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
||||
<div className={"flex-row gap-md"}>
|
||||
<label htmlFor={text_input_id}>Goal:</label>
|
||||
<TextField
|
||||
id={text_input_id}
|
||||
value={data.description}
|
||||
setValue={(val) => setDescription(val)}
|
||||
placeholder={"To ..."}
|
||||
/>
|
||||
</div>
|
||||
<div className={"flex-row gap-md align-center"}>
|
||||
<label htmlFor={checkbox_id}>Achieved:</label>
|
||||
<input
|
||||
id={checkbox_id}
|
||||
type={"checkbox"}
|
||||
value={data.achieved ? "checked" : ""}
|
||||
onChange={(e) => setAchieved(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import {Handle, type NodeProps, Position} from "@xyflow/react";
|
||||
import type {TriggerNode} from "../VisProgTypes.tsx";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import styles from "../../VisProg.module.css";
|
||||
import {RealtimeTextField, TextField} from "../../../../components/TextField.tsx";
|
||||
import {Toolbar} from "./NodeDefinitions.tsx";
|
||||
import {useState} from "react";
|
||||
import duplicateIndices from "../../../../utils/duplicateIndices.ts";
|
||||
|
||||
export type EmotionTriggerNodeProps = {
|
||||
type: "emotion";
|
||||
value: string;
|
||||
}
|
||||
|
||||
type Keyword = { id: string, keyword: string };
|
||||
|
||||
export type KeywordTriggerNodeProps = {
|
||||
type: "keywords";
|
||||
value: Keyword[];
|
||||
}
|
||||
|
||||
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
||||
|
||||
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const text_input_id = "keyword_adder_input";
|
||||
|
||||
return <div className={"flex-row gap-md"}>
|
||||
<label htmlFor={text_input_id}>New Keyword:</label>
|
||||
<RealtimeTextField
|
||||
id={text_input_id}
|
||||
value={input}
|
||||
setValue={setInput}
|
||||
onCommit={() => {
|
||||
if (!input) return;
|
||||
addKeyword(input);
|
||||
setInput("");
|
||||
}}
|
||||
placeholder={"..."}
|
||||
className={"flex-1"}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Keywords({
|
||||
keywords,
|
||||
setKeywords,
|
||||
}: {
|
||||
keywords: Keyword[];
|
||||
setKeywords: (keywords: Keyword[]) => void;
|
||||
}) {
|
||||
type Interpolatable = string | number | boolean | bigint | null | undefined;
|
||||
|
||||
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
|
||||
|
||||
/** Indices of duplicates in the keyword array. */
|
||||
const [duplicates, setDuplicates] = useState<number[]>([]);
|
||||
|
||||
function replace(id: string, value: string) {
|
||||
value = value.trim();
|
||||
const newKeywords = value === ""
|
||||
? keywords.filter((kw) => kw.id != id)
|
||||
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
|
||||
setKeywords(newKeywords);
|
||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||
}
|
||||
|
||||
function add(value: string) {
|
||||
value = value.trim();
|
||||
if (value === "") return;
|
||||
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
|
||||
setKeywords(newKeywords);
|
||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||
}
|
||||
|
||||
return <>
|
||||
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
||||
{[...keywords].map(({id, keyword}, index) => {
|
||||
return <div key={id} className={"flex-row gap-md"}>
|
||||
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
||||
<TextField
|
||||
id={inputElementId(id)}
|
||||
value={keyword}
|
||||
setValue={(val) => replace(id, val)}
|
||||
placeholder={"..."}
|
||||
className={"flex-1"}
|
||||
invalid={duplicates.includes(index)}
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
<KeywordAdder addKeyword={add} />
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function TriggerNodeComponent({
|
||||
id,
|
||||
data,
|
||||
}: NodeProps<TriggerNode>) {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const setKeywords = (keywords: Keyword[]) => {
|
||||
updateNodeData(id, {...data, value: keywords});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
||||
{data.type === "emotion" && (
|
||||
<div className={"flex-row gap-md"}>Emotion?</div>
|
||||
)}
|
||||
{data.type === "keywords" && (
|
||||
<Keywords
|
||||
keywords={data.value}
|
||||
setKeywords={setKeywords}
|
||||
/>
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
29
src/utils/cellStore.ts
Normal file
29
src/utils/cellStore.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {useSyncExternalStore} from "react";
|
||||
|
||||
type Unsub = () => void;
|
||||
|
||||
export type Cell<T> = {
|
||||
get: () => T;
|
||||
set: (next: T | ((prev: T) => T)) => void;
|
||||
subscribe: (callback: () => void) => Unsub;
|
||||
};
|
||||
|
||||
export function cell<T>(initial: T): Cell<T> {
|
||||
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<T>(c: Cell<T>) {
|
||||
return useSyncExternalStore(c.subscribe, c.get, c.get);
|
||||
}
|
||||
19
src/utils/duplicateIndices.ts
Normal file
19
src/utils/duplicateIndices.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Find the indices of all elements that occur more than once.
|
||||
*
|
||||
* @param array The array to search for duplicates.
|
||||
* @returns An array of indices where an element occurs more than once, in no particular order.
|
||||
*/
|
||||
export default function duplicateIndices<T>(array: T[]): number[] {
|
||||
const positions = new Map<T, number[]>();
|
||||
|
||||
array.forEach((value, i) => {
|
||||
if (!positions.has(value)) positions.set(value, []);
|
||||
positions.get(value)!.push(i);
|
||||
});
|
||||
|
||||
// flatten all index lists with more than one element
|
||||
return Array.from(positions.values())
|
||||
.filter(idxs => idxs.length > 1)
|
||||
.flat();
|
||||
}
|
||||
21
src/utils/formatDuration.ts
Normal file
21
src/utils/formatDuration.ts
Normal file
@@ -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')}`;
|
||||
}
|
||||
24
src/utils/priorityFiltering.ts
Normal file
24
src/utils/priorityFiltering.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type PriorityFilterPredicate<T> = {
|
||||
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<T>(element: T, predicates: PriorityFilterPredicate<T>[]): 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;
|
||||
}
|
||||
328
test/components/Logging/Filters.test.tsx
Normal file
328
test/components/Logging/Filters.test.tsx
Normal file
@@ -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<T>(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<string, LogFilterPredicate>();
|
||||
|
||||
const view = render(
|
||||
<Filters
|
||||
filterPredicates={filterPredicates}
|
||||
setFilterPredicates={setFilterPredicates}
|
||||
agentNames={new Set<string>()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Effect sets default to INFO
|
||||
await waitFor(() => {
|
||||
expect(setFilterPredicates).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(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(
|
||||
<Filters
|
||||
filterPredicates={newMap}
|
||||
setFilterPredicates={setFilterPredicates}
|
||||
agentNames={new Set<string>()}
|
||||
/>
|
||||
);
|
||||
|
||||
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<string, LogFilterPredicate>([
|
||||
[
|
||||
GLOBAL,
|
||||
{
|
||||
value: "INFO",
|
||||
priority: 0,
|
||||
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
const setFilterPredicates = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Filters
|
||||
filterPredicates={existing}
|
||||
setFilterPredicates={setFilterPredicates}
|
||||
agentNames={new Set<string>()}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("Global:");
|
||||
await user.selectOptions(select, "ERROR");
|
||||
|
||||
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(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<string, LogFilterPredicate>([
|
||||
[
|
||||
GLOBAL,
|
||||
{
|
||||
value: "WARNING",
|
||||
priority: 0,
|
||||
predicate: (r: any) => r.levelno >= optionMapping.get("WARNING")!
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
const setFilterPredicates = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Filters
|
||||
filterPredicates={existing}
|
||||
setFilterPredicates={setFilterPredicates}
|
||||
agentNames={new Set<string>(["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<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(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<string, LogFilterPredicate>([
|
||||
[
|
||||
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(
|
||||
<Filters
|
||||
filterPredicates={existing}
|
||||
setFilterPredicates={setFilterPredicates}
|
||||
agentNames={new Set(["pepper.speech"])}
|
||||
/>
|
||||
);
|
||||
|
||||
const agentSelect = element.container.querySelector("select#log_level_pepper\\.speech")!;
|
||||
|
||||
await user.selectOptions(agentSelect, "ERROR");
|
||||
|
||||
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(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<string, LogFilterPredicate>([
|
||||
[
|
||||
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(
|
||||
<Filters
|
||||
filterPredicates={existing}
|
||||
setFilterPredicates={setFilterPredicates}
|
||||
agentNames={new Set<string>(["pepper.speech"])}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteBtn = screen.getByRole("button", {name: "speech:"});
|
||||
await user.click(deleteBtn);
|
||||
|
||||
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||
const next = updater(existing);
|
||||
expect(next.has(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filter popup behavior", () => {
|
||||
function renderWithPopupOpen() {
|
||||
const existing = new Map<string, LogFilterPredicate>([
|
||||
[
|
||||
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(
|
||||
<Filters
|
||||
filterPredicates={existing}
|
||||
setFilterPredicates={setFilterPredicates}
|
||||
agentNames={new Set(["pepper.vision"])}
|
||||
/>
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
239
test/components/Logging/Logging.test.tsx
Normal file
239
test/components/Logging/Logging.test.tsx
Normal file
@@ -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<LoggingSettingsState>) => 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<typeof useLogs>;
|
||||
|
||||
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> = {}): LogRecord {
|
||||
return {
|
||||
name: "pepper.logger",
|
||||
message: "default",
|
||||
levelname: "INFO",
|
||||
levelno: 20,
|
||||
created: 1,
|
||||
relativeCreated: 1,
|
||||
firstCreated: 1,
|
||||
firstRelativeCreated: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCell(overrides: Partial<LogRecord> = {}): Cell<LogRecord> {
|
||||
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(<Logging/>);
|
||||
|
||||
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(<Logging/>);
|
||||
|
||||
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(<Logging/>);
|
||||
|
||||
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<string, LogFilterPredicate>) => ({
|
||||
filteredLogs: [],
|
||||
distinctNames: distinct,
|
||||
}));
|
||||
|
||||
render(<Logging/>);
|
||||
|
||||
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<string, LogFilterPredicate>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
246
test/components/Logging/useLogs.test.tsx
Normal file
246
test/components/Logging/useLogs.test.tsx
Normal file
@@ -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<string, any> }) {
|
||||
const { filteredLogs, distinctNames } = useLogs(filters);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="names-count">{distinctNames.size}</div>
|
||||
<ul data-testid="logs">
|
||||
{filteredLogs.map((c, i) => (
|
||||
<LogItem key={i} cell={c} index={i} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogItem({ cell: c, index }: { cell: ReturnType<typeof cell<LogRecord>>; index: number }) {
|
||||
const value = useCell(c);
|
||||
return (
|
||||
<li data-testid={`log-${index}`}>
|
||||
<span data-testid={`log-${index}-name`}>{value.name}</span>
|
||||
<span data-testid={`log-${index}-msg`}>{value.message}</span>
|
||||
<span data-testid={`log-${index}-first`}>{String(value.firstCreated)}</span>
|
||||
<span data-testid={`log-${index}-created`}>{String(value.created)}</span>
|
||||
<span data-testid={`log-${index}-ref`}>{value.reference ?? ""}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<StrictMode>
|
||||
<LogsProbe filters={filters} />
|
||||
</StrictMode>
|
||||
);
|
||||
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(
|
||||
<StrictMode>
|
||||
<LogsProbe filters={filters} />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
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(<LogsProbe filters={filters} />);
|
||||
|
||||
// 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<string, any>();
|
||||
const { rerender } = render(<LogsProbe filters={allowAll} />);
|
||||
|
||||
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<string, any>([["dummy", { value: true }]]);
|
||||
rerender(<LogsProbe filters={blockDebug} />);
|
||||
|
||||
// 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(<LogsProbe filters={allowAll} />);
|
||||
|
||||
// recompute should restore all three
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
0
test/eslint.config.js.ts
Normal file
0
test/eslint.config.js.ts
Normal file
@@ -457,6 +457,7 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'end',
|
||||
connectedNorms: [],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
}]
|
||||
},
|
||||
{
|
||||
@@ -472,6 +473,7 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'phase-2',
|
||||
connectedNorms: [],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
},
|
||||
{
|
||||
phaseNode: {
|
||||
@@ -483,6 +485,7 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'phase-3',
|
||||
connectedNorms: [],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
},
|
||||
{
|
||||
phaseNode: {
|
||||
@@ -494,6 +497,7 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'end',
|
||||
connectedNorms: [],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
}]
|
||||
},
|
||||
{
|
||||
@@ -509,6 +513,7 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'phase-2',
|
||||
connectedNorms: [],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
},
|
||||
{
|
||||
phaseNode: {
|
||||
@@ -525,6 +530,7 @@ describe('Graph Reducer Tests', () => {
|
||||
data: {label: 'Generic Norm', value: "generic"},
|
||||
}],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
},
|
||||
{
|
||||
phaseNode: {
|
||||
@@ -541,6 +547,7 @@ describe('Graph Reducer Tests', () => {
|
||||
data: {label: 'Generic Norm', value: "generic"},
|
||||
}],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
}]
|
||||
},
|
||||
{
|
||||
@@ -561,6 +568,7 @@ describe('Graph Reducer Tests', () => {
|
||||
data: {label: 'Generic Norm', value: "generic"},
|
||||
}],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
},
|
||||
{
|
||||
phaseNode: {
|
||||
@@ -583,6 +591,7 @@ describe('Graph Reducer Tests', () => {
|
||||
data: {label: 'Generic Norm', value: "generic"},
|
||||
}],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
},
|
||||
{
|
||||
phaseNode: {
|
||||
@@ -605,6 +614,7 @@ describe('Graph Reducer Tests', () => {
|
||||
data: {label: 'Generic Norm', value: "generic"},
|
||||
}],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
}]
|
||||
},
|
||||
{
|
||||
@@ -732,6 +742,7 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'end',
|
||||
connectedNorms: [],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
}
|
||||
const output = defaultPhaseReducer(input);
|
||||
expect(output).toEqual({
|
||||
@@ -740,7 +751,8 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'end',
|
||||
phaseData: {
|
||||
norms: [],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -760,6 +772,7 @@ describe('Graph Reducer Tests', () => {
|
||||
data: {label: 'Generic Norm', value: "generic"},
|
||||
}],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [],
|
||||
}
|
||||
const output = defaultPhaseReducer(input);
|
||||
expect(output).toEqual({
|
||||
@@ -772,7 +785,8 @@ describe('Graph Reducer Tests', () => {
|
||||
name: 'Generic Norm',
|
||||
value: "generic"
|
||||
}],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -790,8 +804,9 @@ describe('Graph Reducer Tests', () => {
|
||||
id: 'goal-1',
|
||||
type: 'goal',
|
||||
position: {x: 0, y: 150},
|
||||
data: {label: 'Generic Goal', value: "generic"},
|
||||
data: {label: 'Generic Goal', description: "generic", achieved: false},
|
||||
}],
|
||||
connectedTriggers: [],
|
||||
}
|
||||
const output = defaultPhaseReducer(input);
|
||||
expect(output).toEqual({
|
||||
@@ -803,7 +818,50 @@ describe('Graph Reducer Tests', () => {
|
||||
goals: [{
|
||||
id: 'goal-1',
|
||||
name: 'Generic Goal',
|
||||
value: "generic"
|
||||
description: "generic",
|
||||
achieved: false,
|
||||
}],
|
||||
triggers: [],
|
||||
}
|
||||
});
|
||||
});
|
||||
test("defaultTriggerReducer reduces triggers correctly", () => {
|
||||
const input : PreparedPhase = {
|
||||
phaseNode: {
|
||||
id: 'phase-1',
|
||||
type: 'phase',
|
||||
position: {x: 0, y: 150},
|
||||
data: {label: 'Generic Phase', number: 1},
|
||||
},
|
||||
nextPhaseId: 'end',
|
||||
connectedNorms: [],
|
||||
connectedGoals: [],
|
||||
connectedTriggers: [{
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: {x: 0, y: 150},
|
||||
data: {label: 'Keyword Trigger', type: "keywords", value: [
|
||||
{id: "some_id", keyword: "generic"},
|
||||
{id: "another_id", keyword: "another"},
|
||||
]},
|
||||
}],
|
||||
}
|
||||
const output = defaultPhaseReducer(input);
|
||||
expect(output).toEqual({
|
||||
id: 'phase-1',
|
||||
name: 'Generic Phase',
|
||||
nextPhaseId: 'end',
|
||||
phaseData: {
|
||||
norms: [],
|
||||
goals: [],
|
||||
triggers: [{
|
||||
id: 'trigger-1',
|
||||
label: 'Keyword Trigger',
|
||||
type: "keywords",
|
||||
value: [
|
||||
{id: "some_id", keyword: "generic"},
|
||||
{id: "another_id", keyword: "another"},
|
||||
]
|
||||
}]
|
||||
}
|
||||
});
|
||||
@@ -820,7 +878,8 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'end',
|
||||
phaseData: {
|
||||
norms: [],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
}]
|
||||
},
|
||||
@@ -833,7 +892,8 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'phase-2',
|
||||
phaseData: {
|
||||
norms: [],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -842,7 +902,8 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'phase-3',
|
||||
phaseData: {
|
||||
norms: [],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -851,7 +912,8 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'end',
|
||||
phaseData: {
|
||||
norms: [],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
}]
|
||||
},
|
||||
@@ -864,7 +926,8 @@ describe('Graph Reducer Tests', () => {
|
||||
nextPhaseId: 'phase-2',
|
||||
phaseData: {
|
||||
norms: [],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -879,7 +942,8 @@ describe('Graph Reducer Tests', () => {
|
||||
value: "generic"
|
||||
}
|
||||
],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -892,7 +956,8 @@ describe('Graph Reducer Tests', () => {
|
||||
name: 'Generic Norm',
|
||||
value: "generic"
|
||||
}],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
}]
|
||||
},
|
||||
@@ -909,7 +974,8 @@ describe('Graph Reducer Tests', () => {
|
||||
name: 'Generic Norm',
|
||||
value: "generic"
|
||||
}],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -929,7 +995,8 @@ describe('Graph Reducer Tests', () => {
|
||||
value: "generic"
|
||||
}
|
||||
],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -947,7 +1014,8 @@ describe('Graph Reducer Tests', () => {
|
||||
name: 'Generic Norm',
|
||||
value: "generic"
|
||||
}],
|
||||
goals: []
|
||||
goals: [],
|
||||
triggers: [],
|
||||
}
|
||||
}]
|
||||
},
|
||||
|
||||
156
test/utils/cellStore.test.tsx
Normal file
156
test/utils/cellStore.test.tsx
Normal file
@@ -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<any>; label: string }) {
|
||||
const v = useCell(c);
|
||||
// count renders to verify re-render behavior
|
||||
(View as any).__renders = ((View as any).__renders ?? 0) + 1;
|
||||
return <div data-testid={label}>{String(v)}</div>;
|
||||
}
|
||||
|
||||
it("reads initial value and updates on set", () => {
|
||||
const c = cell("hello");
|
||||
|
||||
render(<View c={c} label="value"/>);
|
||||
|
||||
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(<View c={c} label="num"/>);
|
||||
|
||||
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(<View c={c} label="value"/>);
|
||||
|
||||
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 <div data-testid="A">{v}</div>;
|
||||
}
|
||||
|
||||
function B() {
|
||||
const v = useCell(b);
|
||||
rendersB++;
|
||||
return <div data-testid="B">{v}</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<>
|
||||
<A/>
|
||||
<B/>
|
||||
</>
|
||||
);
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
22
test/utils/duplicateIndices.test.ts
Normal file
22
test/utils/duplicateIndices.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import duplicateIndices from "../../src/utils/duplicateIndices.ts";
|
||||
|
||||
describe("duplicateIndices (unit)", () => {
|
||||
it("returns an empty array for empty input", () => {
|
||||
expect(duplicateIndices<number>([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when no duplicates exist", () => {
|
||||
expect(duplicateIndices([1, 2, 3, 4])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all positions for every duplicated value", () => {
|
||||
const result = duplicateIndices(["a", "b", "a", "c", "b", "b"]);
|
||||
expect(result.sort()).toEqual([0, 1, 2, 4, 5]);
|
||||
});
|
||||
|
||||
it("only treats identical references as duplicate objects", () => {
|
||||
const shared = { v: 1 };
|
||||
const result = duplicateIndices([shared, { v: 1 }, shared, shared]);
|
||||
expect(result.sort()).toEqual([0, 2, 3]);
|
||||
});
|
||||
});
|
||||
53
test/utils/formatDuration.test.ts
Normal file
53
test/utils/formatDuration.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
81
test/utils/priorityFiltering.test.ts
Normal file
81
test/utils/priorityFiltering.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../src/utils/priorityFiltering";
|
||||
|
||||
const makePred = <T>(priority: number, fn: (el: T) => boolean | null): PriorityFilterPredicate<T> => ({
|
||||
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<number>(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<number>(1, (_) => false);
|
||||
const lowPass = makePred<number>(1, (_) => true);
|
||||
const highPass = makePred<number>(10, (n) => n > 0);
|
||||
const highFail = makePred<number>(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<number>(5, (n) => n % 2 === 0);
|
||||
const high2 = makePred<number>(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<number>(1, (_) => false);
|
||||
const highTrue = makePred<number>(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<number>(1, (_) => false);
|
||||
const p3 = makePred<number>(3, (_) => false);
|
||||
const p5 = makePred<number>(5, (n) => n > 0);
|
||||
expect(applyPriorityPredicates(1, [p1, p3, p5])).toBe(true);
|
||||
});
|
||||
|
||||
it("skips predicates that return null", () => {
|
||||
const high = makePred<number>(10, (n) => n === 0 ? true : null);
|
||||
const low = makePred<number>(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<number>(0, (_) => false);
|
||||
const high1 = makePred<number>(5, (n) => n % 2 === 0);
|
||||
const high2 = makePred<number>(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<number>(0, (_) => false);
|
||||
const high = makePred<number>(5, (n) => n === 3 ? true : null);
|
||||
const result = elems.filter((e) => applyPriorityPredicates(e, [low, high]));
|
||||
expect(result).toEqual([3]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user