diff --git a/src/pages/SimpleProgram/SimpleProgram.module.css b/src/pages/SimpleProgram/SimpleProgram.module.css new file mode 100644 index 0000000..69cc65c --- /dev/null +++ b/src/pages/SimpleProgram/SimpleProgram.module.css @@ -0,0 +1,167 @@ +/* ---------- Layout ---------- */ + +.container { + height: 100%; + display: flex; + flex-direction: column; + background: #1e1e1e; + color: #f5f5f5; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: clamp(0.75rem, 2vw, 1.25rem); + background: #2a2a2a; + border-bottom: 1px solid #3a3a3a; +} + +.header h2 { + font-size: clamp(1rem, 2.2vw, 1.4rem); + font-weight: 600; +} + +.controls button { + margin-left: 0.5rem; + padding: 0.4rem 0.9rem; + border-radius: 6px; + border: none; + background: #111; + color: white; + cursor: pointer; +} + +.controls button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ---------- Content ---------- */ + +.content { + flex: 1; + padding: 2%; +} + +/* ---------- Grid ---------- */ + +.phaseGrid { + height: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 2%; +} + +/* ---------- Box ---------- */ + +.box { + display: flex; + flex-direction: column; + background: #ffffff; + color: #1e1e1e; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); +} + +.boxHeader { + padding: 0.6rem 0.9rem; + background: linear-gradient(135deg, #dcdcdc, #e9e9e9); + font-style: italic; + font-weight: 500; + font-size: clamp(0.9rem, 1.5vw, 1.05rem); + border-bottom: 1px solid #cfcfcf; +} + +.boxContent { + flex: 1; + padding: 0.8rem 1rem; + overflow-y: auto; +} + +/* ---------- Lists ---------- */ + +.iconList { + list-style: none; + padding: 0; + margin: 0; +} + +.iconList li { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.5rem; + font-size: clamp(0.85rem, 1.3vw, 1rem); +} + +.bulletList { + margin: 0; + padding-left: 1.2rem; +} + +.bulletList li { + margin-bottom: 0.4rem; +} + +/* ---------- Icons ---------- */ + +.successIcon, +.failIcon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + border-radius: 4px; + font-weight: bold; + color: white; + flex-shrink: 0; +} + +.successIcon { + background: #3cb371; +} + +.failIcon { + background: #e5533d; +} + +/* ---------- Empty ---------- */ + +.empty { + opacity: 0.55; + font-style: italic; + font-size: 0.9rem; +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 900px) { + .phaseGrid { + grid-template-columns: 1fr; + grid-template-rows: repeat(4, minmax(0, 1fr)); + gap: 1rem; + } +} + +.leftControls { + display: flex; + align-items: center; + gap: 1rem; +} + +.backButton { + background: transparent; + border: 1px solid #555; + color: #ddd; + padding: 0.35rem 0.75rem; + border-radius: 6px; + cursor: pointer; +} + +.backButton:hover { + background: #333; +} diff --git a/src/pages/SimpleProgram/SimpleProgram.tsx b/src/pages/SimpleProgram/SimpleProgram.tsx new file mode 100644 index 0000000..0f63653 --- /dev/null +++ b/src/pages/SimpleProgram/SimpleProgram.tsx @@ -0,0 +1,192 @@ +import React from "react"; +import styles from "./SimpleProgram.module.css"; +import useProgramStore from "../../utils/programStore.ts"; + +/** + * Generic container box with a header and content area. + */ +type BoxProps = { + title: string; + children: React.ReactNode; +}; + +const Box: React.FC = ({ title, children }) => ( +
+
{title}
+
{children}
+
+); + +/** + * Renders a list of goals for a phase. + * Expects goal-like objects from the program store. + */ +const GoalList: React.FC<{ goals: unknown[] }> = ({ goals }) => { + if (!goals.length) { + return

No goals defined.

; + } + + return ( + + ); +}; + +/** + * Renders a list of triggers for a phase. + */ +const TriggerList: React.FC<{ triggers: unknown[] }> = ({ triggers }) => { + if (!triggers.length) { + return

No triggers defined.

; + } + + return ( + + ); +}; + +/** + * Renders a list of norms for a phase. + */ +const NormList: React.FC<{ norms: unknown[] }> = ({ norms }) => { + if (!norms.length) { + return

No norms defined.

; + } + + return ( + + ); +}; + +/** + * Displays all phase-related information in a grid layout. + */ +type PhaseGridProps = { + norms: unknown[]; + goals: unknown[]; + triggers: unknown[]; +}; + +const PhaseGrid: React.FC = ({ + norms, + goals, + triggers, +}) => ( +
+ + + + + + + + + + + + + +

No conditional norms defined.

+
+
+); + +/** + * Main program viewer. + * Reads all data from the program store and allows + * navigating between phases. + */ +const SimpleProgram: React.FC = () => { + const getPhaseIds = useProgramStore((s) => s.getPhaseIds); + const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase); + const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase); + const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase); + + const phaseIds = getPhaseIds(); + const [phaseIndex, setPhaseIndex] = React.useState(0); + + if (phaseIds.length === 0) { + return

No program loaded.

; + } + + const phaseId = phaseIds[phaseIndex]; + + return ( +
+
+

+ Phase {phaseIndex + 1} / {phaseIds.length} +

+ +
+ + + +
+
+ +
+ +
+
+ ); +}; + +export default SimpleProgram; diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 1a3720b..5311feb 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -7,7 +7,7 @@ import { MarkerType, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import {useEffect} from "react"; +import {useEffect, useState} from "react"; import {useShallow} from 'zustand/react/shallow'; import useProgramStore from "../../utils/programStore.ts"; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; @@ -16,6 +16,7 @@ import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts'; import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx'; +import SimpleProgram from "../SimpleProgram/SimpleProgram.tsx"; // --| config starting params for flow |-- @@ -179,10 +180,31 @@ function graphReducer() { * @constructor */ function VisProgPage() { + const [showSimpleProgram, setShowSimpleProgram] = useState(false); + const setProgramState = useProgramStore((state) => state.setProgramState); + + const onClick = () => { + const phases = graphReducer(); // reduce graph + setProgramState({ phases }); // <-- save to store + setShowSimpleProgram(true); // show SimpleProgram + runProgram(); // send to backend if needed + }; + + if (showSimpleProgram) { + return ( +
+ + +
+ ); + } + return ( <> - + ) } diff --git a/test/pages/simpleProgram/SimpleProgram.test.tsx b/test/pages/simpleProgram/SimpleProgram.test.tsx new file mode 100644 index 0000000..dcb56ba --- /dev/null +++ b/test/pages/simpleProgram/SimpleProgram.test.tsx @@ -0,0 +1,176 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram"; +import useProgramStore from "../../../src/utils/programStore"; + +/** + * Helper to preload the program store before rendering. + */ +function loadProgram(phases: Record[]) { + useProgramStore.getState().setProgramState({ phases }); +} + +describe("SimpleProgram", () => { + beforeEach(() => { + loadProgram([]); + }); + + test("shows empty state when no program is loaded", () => { + render(); + expect(screen.getByText("No program loaded.")).toBeInTheDocument(); + }); + + test("renders first phase content", () => { + loadProgram([ + { + id: "phase-1", + norms: [{ id: "n1", norm: "Be polite" }], + goals: [{ id: "g1", description: "Finish task", achieved: true }], + triggers: [{ id: "t1", label: "Keyword trigger" }], + }, + ]); + + render(); + + expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument(); + expect(screen.getByText("Be polite")).toBeInTheDocument(); + expect(screen.getByText("Finish task")).toBeInTheDocument(); + expect(screen.getByText("Keyword trigger")).toBeInTheDocument(); + }); + + test("renders empty messages when phase has no data", () => { + loadProgram([ + { + id: "phase-1", + norms: [], + goals: [], + triggers: [], + }, + ]); + + render(); + + expect(screen.getAllByText("No norms defined.").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("No goals defined.").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("No triggers defined.").length).toBeGreaterThanOrEqual(1); + expect( + screen.getByText("No conditional norms defined.") + ).toBeInTheDocument(); + }); + + test("allows navigating between phases", () => { + loadProgram([ + { + id: "phase-1", + norms: [], + goals: [], + triggers: [], + }, + { + id: "phase-2", + norms: [{ id: "n2", norm: "Be careful" }], + goals: [], + triggers: [], + }, + ]); + + render(); + + expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Next ▶")); + + expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument(); + expect(screen.getByText("Be careful")).toBeInTheDocument(); + }); + + test("prev button is disabled on first phase", () => { + loadProgram([{ id: "phase-1", norms: [], goals: [], triggers: [] }]); + + render(); + expect(screen.getByText("◀ Prev")).toBeDisabled(); + }); + + test("next button is disabled on last phase", () => { + loadProgram([{ id: "phase-1", norms: [], goals: [], triggers: [] }]); + + render(); + expect(screen.getByText("Next ▶")).toBeDisabled(); + }); + + test("prev and next buttons enable/disable correctly when navigating", () => { + loadProgram([ + { id: "p1", norms: [], goals: [], triggers: [] }, + { id: "p2", norms: [], goals: [], triggers: [] }, + ]); + + render(); + + const prev = screen.getByText("◀ Prev"); + const next = screen.getByText("Next ▶"); + + expect(prev).toBeDisabled(); + expect(next).not.toBeDisabled(); + + fireEvent.click(next); + + expect(prev).not.toBeDisabled(); + expect(next).toBeDisabled(); + }); + + test("renders achieved and unachieved goals with correct icons", () => { + loadProgram([ + { + id: "phase-1", + norms: [], + goals: [ + { id: "g1", description: "Done goal", achieved: true }, + { id: "g2", description: "Failed goal", achieved: false }, + ], + triggers: [], + }, + ]); + + render(); + + expect(screen.getByText("✔")).toBeInTheDocument(); + expect(screen.getByText("✖")).toBeInTheDocument(); + expect(screen.getByText("Done goal")).toBeInTheDocument(); + expect(screen.getByText("Failed goal")).toBeInTheDocument(); + }); + + test("renders fallback labels when optional fields are missing", () => { + loadProgram([ + { + id: "phase-1", + norms: [{}], + goals: [{}], + triggers: [{}], + }, + ]); + + render(); + + expect(screen.getByText("Unnamed norm")).toBeInTheDocument(); + expect(screen.getByText("Unnamed goal")).toBeInTheDocument(); + expect(screen.getByText("Unnamed trigger")).toBeInTheDocument(); + }); + + test("does not crash when navigating beyond boundaries", () => { + loadProgram([ + { id: "p1", norms: [], goals: [], triggers: [] }, + { id: "p2", norms: [], goals: [], triggers: [] }, + ]); + + render(); + + fireEvent.click(screen.getByText("Next ▶")); + fireEvent.click(screen.getByText("Next ▶")); + + expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("◀ Prev")); + fireEvent.click(screen.getByText("◀ Prev")); + + expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument(); + }); +});