diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..247b8e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist/ +.git +.gitignore +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d38a19b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# ── Production image ── +FROM nginx:alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Unpack pre-built Vite output +COPY dist.tar.gz /usr/share/nginx/html/ +RUN tar -xzf /usr/share/nginx/html/dist.tar.gz --strip-components=1 -C /usr/share/nginx/html \ + && rm /usr/share/nginx/html/dist.tar.gz + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/dist.tar.gz b/dist.tar.gz new file mode 100644 index 0000000..ef4c93b Binary files /dev/null and b/dist.tar.gz differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..15b12be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + pss: + build: . + ports: + - "${PORT:-80}:80" + restart: unless-stopped diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..ba3284d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + # Cache para assets com hash no nome (gerados pelo Vite) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA: todas as rotas vão para index.html + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package-lock.json b/package-lock.json index b1e5b33..0ecf887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "recharts": "^3.8.0", "tailwind-merge": "^3.5.0", "zustand": "^5.0.12" }, @@ -146,7 +147,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -519,7 +519,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -568,7 +567,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1122,6 +1120,42 @@ "node": ">=14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1479,6 +1513,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -1846,7 +1892,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1904,6 +1951,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1924,7 +2034,6 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1942,7 +2051,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1954,11 +2062,16 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2260,7 +2373,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2412,6 +2524,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2451,6 +2684,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2486,7 +2725,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -2543,6 +2783,16 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2605,6 +2855,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2767,6 +3023,16 @@ "dev": true, "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2777,6 +3043,15 @@ "node": ">=8" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2893,7 +3168,6 @@ "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@asamuzakjp/dom-selector": "^7.0.2", @@ -3270,6 +3544,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3485,7 +3760,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3528,6 +3802,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3543,6 +3818,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3553,6 +3829,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -3575,7 +3852,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3588,7 +3864,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3601,8 +3876,31 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } }, "node_modules/react-refresh": { "version": "0.17.0", @@ -3614,6 +3912,36 @@ "node": ">=0.10.0" } }, + "node_modules/recharts": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3628,6 +3956,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3638,6 +3981,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -3985,6 +4334,12 @@ "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4154,13 +4509,43 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -4259,7 +4644,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/package.json b/package.json index 6786a1e..e710e95 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "build:release": "tsc -b && vite build && tar --dereference -czf dist.tar.gz dist", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", @@ -16,6 +17,7 @@ "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "recharts": "^3.8.0", "tailwind-merge": "^3.5.0", "zustand": "^5.0.12" }, diff --git a/src/App.tsx b/src/App.tsx index e2a1136..21cbe6b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,8 @@ import AppLayout from './components/Layout/AppLayout'; +import { useSimulation } from './hooks/useSimulation'; function App() { + useSimulation(); return ( ); diff --git a/src/components/GanttChart/GanttCell.tsx b/src/components/GanttChart/GanttCell.tsx new file mode 100644 index 0000000..f9ab05b --- /dev/null +++ b/src/components/GanttChart/GanttCell.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import type { Process, SimTick, StateLabel } from "@core/types"; +import { CELL_H, CELL_W } from "./constants"; + +// ── colour map ────────────────────────────────────────────── +const STATE_CLASS: Record = { + New: "bg-[var(--color-state-new)]", + Ready: "bg-[var(--color-state-ready)]", + Running: "bg-[var(--color-state-running)]", + Waiting: "bg-[var(--color-state-waiting)]", + Terminated: "bg-[var(--color-state-terminated)]", +}; + +// ── props ──────────────────────────────────────────────────── +interface GanttCellProps { + tick: SimTick; + process: Process; + state: StateLabel; + isCtxSwitch: boolean; + onMouseEnter: (e: React.MouseEvent, tick: SimTick, process: Process, state: StateLabel, isCtxSwitch: boolean) => void; + onMouseLeave: () => void; +} + +// ── component ─────────────────────────────────────────────── +const GanttCell = React.memo(function GanttCell({ + tick, + process, + state, + isCtxSwitch, + onMouseEnter, + onMouseLeave, +}: GanttCellProps) { + const bg = isCtxSwitch + ? "bg-[var(--color-state-ctx)]" + : STATE_CLASS[state]; + + // Running cells get a coloured left border using the process colour + const borderStyle = + !isCtxSwitch && state === "Running" + ? { borderLeft: `3px solid ${process.color}` } + : undefined; + + return ( +
onMouseEnter(e, tick, process, state, isCtxSwitch)} + onMouseLeave={onMouseLeave} + /> + ); +}); + +export default GanttCell; diff --git a/src/components/GanttChart/GanttChart.tsx b/src/components/GanttChart/GanttChart.tsx new file mode 100644 index 0000000..b671f84 --- /dev/null +++ b/src/components/GanttChart/GanttChart.tsx @@ -0,0 +1,113 @@ +import { useCallback, useRef, useState } from "react"; +import { useSimulationStore } from "@store/simulationStore"; +import { useProcessStore } from "@store/processStore"; +import { useGanttScroll } from "@hooks/useGanttScroll"; +import GanttRow from "./GanttRow"; +import GanttLegend from "./GanttLegend"; +import GanttTooltip, { type TooltipData } from "./GanttTooltip"; +import { CELL_H, CELL_W } from "./constants"; + +// ── GanttChart ──────────────────────────────────────────────── +const GanttChart = () => { + const ticks = useSimulationStore((s) => s.ticks); + const status = useSimulationStore((s) => s.status); + const processes = useProcessStore((s) => s.processes); + + // Scrollable container ref for auto-scroll + const scrollRef = useRef(null); + const currentTickIndex = ticks.length - 1; + useGanttScroll(scrollRef, currentTickIndex, status === "running"); + + // Tooltip state + const [tooltip, setTooltip] = useState(null); + const handleMouseLeave = useCallback(() => setTooltip(null), []); + + // Empty state + if (ticks.length === 0) { + return ( +
+ Inicie a simulação para ver o diagrama de Gantt +
+ ); + } + + // ── ruler (tick numbers) ────────────────────────────────── + const tickNumbers = ticks.map((t) => t.tick); + const totalWidth = ticks.length * CELL_W; + + return ( +
+ + {/* ── scrollable grid + cursor ── */} +
+
+ + {/* Tick ruler */} +
+ {/* Spacer for sticky process labels column */} +
+ + {tickNumbers.map((t) => ( +
+ {t % 5 === 0 ? t : ""} +
+ ))} +
+ + {/* Process rows */} +
+ {processes.map((process) => ( +
+ {/* Process label (sticky on the left) */} +
+ + {process.name} + +
+ + +
+ ))} + + {/* Current-tick cursor line */} + {currentTickIndex >= 0 && ( +
+ )} +
+ +
+
+ + {/* ── process name labels (sticky left, overlay) ── */} + {/* Rendered OUTSIDE the scrollable div so they stay fixed on the left */} + {/* We achieve this with a separate absolute-positioned column */} + + {/* ── legend ── */} + + + {/* ── tooltip ── */} + {tooltip && } +
+ ); +}; + +export default GanttChart; diff --git a/src/components/GanttChart/GanttLegend.tsx b/src/components/GanttChart/GanttLegend.tsx new file mode 100644 index 0000000..7c8c858 --- /dev/null +++ b/src/components/GanttChart/GanttLegend.tsx @@ -0,0 +1,25 @@ +// Legend items ordered as they appear in a typical lifecycle +const ITEMS: { label: string; cssVar: string; textClass: string }[] = [ + { label: "New", cssVar: "var(--color-state-new)", textClass: "text-white" }, + { label: "Ready", cssVar: "var(--color-state-ready)", textClass: "text-slate-900" }, + { label: "Running", cssVar: "var(--color-state-running)", textClass: "text-slate-900" }, + { label: "Waiting (I/O)", cssVar: "var(--color-state-waiting)", textClass: "text-slate-900" }, + { label: "Terminated", cssVar: "var(--color-state-terminated)", textClass: "text-slate-900" }, + { label: "Context Switch",cssVar: "var(--color-state-ctx)", textClass: "text-white" }, +]; + +const GanttLegend = () => ( +
+ {ITEMS.map(({ label, cssVar, textClass }) => ( +
+ + {label} +
+ ))} +
+); + +export default GanttLegend; diff --git a/src/components/GanttChart/GanttRow.tsx b/src/components/GanttChart/GanttRow.tsx new file mode 100644 index 0000000..85a3098 --- /dev/null +++ b/src/components/GanttChart/GanttRow.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import type { Process, SimTick, StateLabel } from "@core/types"; +import GanttCell from "./GanttCell"; +import { CELL_H, CELL_W } from "./constants"; +import type { TooltipData } from "./GanttTooltip"; + +interface GanttRowProps { + process: Process; + ticks: SimTick[]; + onCellMouseEnter: (data: TooltipData) => void; + onCellMouseLeave: () => void; +} + +const GanttRow = React.memo(function GanttRow({ + process, + ticks, + onCellMouseEnter, + onCellMouseLeave, +}: GanttRowProps) { + return ( +
+ {ticks.map((tick) => { + const state: StateLabel = tick.states[process.id] ?? "New"; + // A context-switch tick affects the process currently on CPU + const isCtxSwitch = + tick.contextSwitching && tick.cpuProcess === process.id; + + return ( + + onCellMouseEnter({ tick: t, process: p, state: s, isCtxSwitch: ctx, x: e.clientX, y: e.clientY }) + } + onMouseLeave={onCellMouseLeave} + /> + ); + })} + + {/* Empty filler so the row is always at least CELL_W wide */} + {ticks.length === 0 && ( +
+ )} +
+ ); +}); + +export default GanttRow; diff --git a/src/components/GanttChart/GanttTooltip.tsx b/src/components/GanttChart/GanttTooltip.tsx new file mode 100644 index 0000000..b699a98 --- /dev/null +++ b/src/components/GanttChart/GanttTooltip.tsx @@ -0,0 +1,80 @@ +import type { Process, SimTick, StateLabel } from "@core/types"; + +// ── colour labels ──────────────────────────────────────────── +const STATE_LABEL: Record = { + New: "New", + Ready: "Ready", + Running: "Running", + Waiting: "Waiting (I/O)", + Terminated: "Terminated", +}; + +const STATE_BADGE: Record = { + New: "bg-[var(--color-state-new)] text-white", + Ready: "bg-[var(--color-state-ready)] text-slate-900", + Running: "bg-[var(--color-state-running)] text-slate-900", + Waiting: "bg-[var(--color-state-waiting)] text-slate-900", + Terminated: "bg-[var(--color-state-terminated)] text-slate-900", +}; + +// ── types ──────────────────────────────────────────────────── +export interface TooltipData { + tick: SimTick; + process: Process; + state: StateLabel; + isCtxSwitch: boolean; + x: number; + y: number; +} + +interface GanttTooltipProps { + data: TooltipData; +} + +// ── component ──────────────────────────────────────────────── +const GanttTooltip = ({ data }: GanttTooltipProps) => { + const { tick, process, state, isCtxSwitch, x, y } = data; + + // Keep the tooltip fully inside the viewport + const left = Math.min(x + 12, window.innerWidth - 220); + const top = Math.min(y + 12, window.innerHeight - 140); + + return ( +
+ {/* Header */} +
+ + {process.name} + + tick {tick.tick} +
+ + {/* State badge */} +
+ {isCtxSwitch ? ( + + ⚠ Context Switch + + ) : ( + + {STATE_LABEL[state]} + + )} +
+ + {/* Queue context */} + {tick.readyQueue.length > 0 && ( +

+ Ready queue: {tick.readyQueue.length} process{tick.readyQueue.length !== 1 ? "es" : ""} +

+ )} +
+ ); +}; + +export default GanttTooltip; diff --git a/src/components/GanttChart/__tests__/GanttCell.test.tsx b/src/components/GanttChart/__tests__/GanttCell.test.tsx new file mode 100644 index 0000000..9eea9b5 --- /dev/null +++ b/src/components/GanttChart/__tests__/GanttCell.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, fireEvent } from "@testing-library/react"; +import GanttCell from "../GanttCell"; +import type { Process, SimTick } from "@core/types"; + +describe("GanttCell", () => { + const mockProcess: Process = { + id: "p1", + name: "P1", + arrivalTime: 0, + priority: 5, + color: "#ff0000", + bursts: [{ type: "cpu", duration: 5 }], + }; + + const mockTick: SimTick = { + tick: 1, + cpuProcess: "p1", + ioProcesses: [], + readyQueue: [], + states: { p1: "Running" }, + contextSwitching: false, + }; + + const noop = () => {}; + + it("renders with the correct background color for Running state", () => { + const { container } = render( + + ); + const cell = container.firstChild as HTMLElement; + expect(cell.className).toContain("bg-[var(--color-state-running)]"); + }); + + it("renders with the context switch color when isCtxSwitch is true", () => { + const { container } = render( + + ); + const cell = container.firstChild as HTMLElement; + expect(cell.className).toContain("bg-[var(--color-state-ctx)]"); + }); + + it("calls onMouseEnter with correct arguments", () => { + const onMouseEnter = vi.fn(); + const { container } = render( + + ); + const cell = container.firstChild as HTMLElement; + fireEvent.mouseEnter(cell); + expect(onMouseEnter).toHaveBeenCalledWith( + expect.anything(), + mockTick, + mockProcess, + "Running", + false + ); + }); +}); diff --git a/src/components/GanttChart/constants.ts b/src/components/GanttChart/constants.ts new file mode 100644 index 0000000..ccf3a54 --- /dev/null +++ b/src/components/GanttChart/constants.ts @@ -0,0 +1,5 @@ +/** Width of a single tick cell (px) */ +export const CELL_W = 28; + +/** Height of a single process row (px) */ +export const CELL_H = 32; diff --git a/src/components/Layout/AppHeader.tsx b/src/components/Layout/AppHeader.tsx new file mode 100644 index 0000000..38b5a04 --- /dev/null +++ b/src/components/Layout/AppHeader.tsx @@ -0,0 +1,150 @@ +import { useState, useRef } from 'react'; +import { ChevronDown, Download, Upload, BookOpen } from 'lucide-react'; +import { presets } from '@core/presets'; +import { useProcessStore } from '../../store/processStore'; +import { useSimulationStore } from '../../store/simulationStore'; + +const AppHeader = () => { + const [presetsOpen, setPresetsOpen] = useState(false); + const fileInputRef = useRef(null); + + const loadPreset = useProcessStore(s => s.loadPreset); + const processes = useProcessStore(s => s.processes); + const config = useProcessStore(s => s.config); + const simStatus = useSimulationStore(s => s.status); + + const handleLoadPreset = (index: number) => { + const p = presets[index]; + if (!p) return; + loadPreset(p.processes, p.config); + setPresetsOpen(false); + }; + + const handleExportJSON = () => { + const data = JSON.stringify({ processes, config }, null, 2); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'pss-config.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleImportJSON = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const parsed = JSON.parse(ev.target?.result as string); + if (parsed.processes && parsed.config) { + loadPreset(parsed.processes, parsed.config); + } + } catch { + // Silently ignore malformed JSON + } + }; + reader.readAsText(file); + // Reset so the same file can be imported again + e.target.value = ''; + }; + + const isSimulationActive = simStatus !== 'idle'; + + return ( +
+ {/* Title */} +

+ Process Scheduler Simulator +

+ +
+ + {/* Presets dropdown */} +
+ + + {presetsOpen && ( + <> + {/* Backdrop */} +
setPresetsOpen(false)} + /> + {/* Dropdown */} +
+
+ Cenários pré-definidos +
+ {presets.map((preset, i) => ( + + ))} +
+ + )} +
+ + {/* Import JSON */} + + + + {/* Export JSON */} + +
+ ); +}; + +export default AppHeader; diff --git a/src/components/Layout/AppLayout.tsx b/src/components/Layout/AppLayout.tsx index 20ec488..19ff2a6 100644 --- a/src/components/Layout/AppLayout.tsx +++ b/src/components/Layout/AppLayout.tsx @@ -1,11 +1,15 @@ +import AppHeader from './AppHeader'; import Sidebar from './Sidebar'; import MainArea from './MainArea'; const AppLayout = () => { return ( -
- - +
+ +
+ + +
); }; diff --git a/src/components/Layout/MainArea.tsx b/src/components/Layout/MainArea.tsx index 4b041f5..cd543f0 100644 --- a/src/components/Layout/MainArea.tsx +++ b/src/components/Layout/MainArea.tsx @@ -1,23 +1,13 @@ import ControlBar from '../ControlBar/ControlBar'; +import GanttChart from '../GanttChart/GanttChart'; +import MetricsPanel from '../MetricsPanel/MetricsPanel'; const MainArea = () => { return (
-
- - - {/* Placeholder for GanttChart — Phase 4 */} -
- [ Gantt Chart — Fase 4 ] -
- - {/* Placeholder for MetricsPanel — Phase 5 */} -
-
- [ Metrics Panel — Fase 5 ] -
-
-
+ + +
); }; diff --git a/src/components/MetricsPanel/GlobalMetrics.tsx b/src/components/MetricsPanel/GlobalMetrics.tsx new file mode 100644 index 0000000..7d39215 --- /dev/null +++ b/src/components/MetricsPanel/GlobalMetrics.tsx @@ -0,0 +1,83 @@ +import type { GlobalMetrics as GlobalMetricsType } from '@core/types'; + +interface GlobalMetricsProps { + metrics: GlobalMetricsType; +} + +interface MetricCardProps { + label: string; + value: string; + sub?: string; + accent?: string; +} + +const MetricCard = ({ label, value, sub, accent = 'text-blue-300' }: MetricCardProps) => ( +
+ {label} + {value} + {sub && {sub}} +
+); + +const GlobalMetrics = ({ metrics }: GlobalMetricsProps) => { + const { + avgResponseTime, + avgTurnaroundTime, + avgWaitingTime, + cpuThroughput, + cpuUtilization, + totalSimulationTime, + } = metrics; + + const utilizationPct = Math.min(100, cpuUtilization); + + return ( +
+
+ + + + + + {/* CPU Utilization card with progress bar */} +
+ CPU Utilization + {utilizationPct.toFixed(1)}% +
+
+
+
+
+
+ ); +}; + +export default GlobalMetrics; diff --git a/src/components/MetricsPanel/MetricsChart.tsx b/src/components/MetricsPanel/MetricsChart.tsx new file mode 100644 index 0000000..27bf118 --- /dev/null +++ b/src/components/MetricsPanel/MetricsChart.tsx @@ -0,0 +1,82 @@ +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + ResponsiveContainer, + CartesianGrid, +} from 'recharts'; +import type { ProcessMetrics, Process } from '@core/types'; + +interface MetricsChartProps { + metrics: ProcessMetrics[]; + processMap: Record; +} + +const MetricsChart = ({ metrics, processMap }: MetricsChartProps) => { + if (metrics.length === 0) { + return ( +
+ Nenhum dado disponível. +
+ ); + } + + const data = metrics.map((m) => ({ + name: processMap[m.processId]?.name ?? m.processId, + 'Waiting Time': m.waitingTime, + 'CPU Time': m.cpuTime, + 'I/O Time': m.ioTime, + })); + + return ( +
+ + + + + + + + + + + + +

+ Barras empilhadas = Turnaround Time (Waiting + CPU + I/O) +

+
+ ); +}; + +export default MetricsChart; diff --git a/src/components/MetricsPanel/MetricsPanel.tsx b/src/components/MetricsPanel/MetricsPanel.tsx new file mode 100644 index 0000000..8a6c7fb --- /dev/null +++ b/src/components/MetricsPanel/MetricsPanel.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { Download, BarChart2, Table, Globe } from 'lucide-react'; +import { computeProcessMetrics, computeGlobalMetrics } from '@core/metrics'; +import { useSimulationStore } from '../../store/simulationStore'; +import { useProcessStore } from '../../store/processStore'; +import { useExport } from '../../hooks/useExport'; +import MetricsTable from './MetricsTable'; +import GlobalMetrics from './GlobalMetrics'; +import MetricsChart from './MetricsChart'; + +type Tab = 'table' | 'global' | 'chart'; + +const TAB_CONFIG: { id: Tab; label: string; icon: React.ReactNode }[] = [ + { id: 'table', label: 'Por Processo', icon: }, + { id: 'global', label: 'Global', icon: }, + { id: 'chart', label: 'Gráfico', icon: }, +]; + +const MetricsPanel = () => { + const [activeTab, setActiveTab] = useState('table'); + + const engine = useSimulationStore(s => s.engine); + const ticks = useSimulationStore(s => s.ticks); + const status = useSimulationStore(s => s.status); + const processes = useProcessStore(s => s.processes); + + const { exportCSV } = useExport(); + + const hasData = ticks.length > 0; + + if (!hasData) { + return ( +
+ Execute a simulação para ver as métricas. +
+ ); + } + + // Derive metrics from engine runtime state + const runtimes = engine?.runtimeStates ?? []; + const completedRuntimes = runtimes.filter(rt => rt.finishTick !== null); + const processMetrics = completedRuntimes.map(rt => computeProcessMetrics(rt)); + const globalMetrics = computeGlobalMetrics(processMetrics, ticks.length); + const processMap = Object.fromEntries(processes.map(p => [p.id, p])); + + const canExportCSV = status === 'finished' && processMetrics.length > 0; + + return ( +
+ {/* Panel header with tabs + export button */} +
+ {TAB_CONFIG.map(tab => ( + + ))} + +
+ +
+
+ + {/* Tab content */} +
+ {activeTab === 'table' && ( + + )} + {activeTab === 'global' && ( + + )} + {activeTab === 'chart' && ( + + )} +
+
+ ); +}; + +export default MetricsPanel; diff --git a/src/components/MetricsPanel/MetricsTable.tsx b/src/components/MetricsPanel/MetricsTable.tsx new file mode 100644 index 0000000..7602d84 --- /dev/null +++ b/src/components/MetricsPanel/MetricsTable.tsx @@ -0,0 +1,82 @@ +import type { ProcessMetrics, Process } from '@core/types'; +import Badge from '../shared/Badge'; + +interface MetricsTableProps { + metrics: ProcessMetrics[]; + processMap: Record; +} + +function boundVariant(type: ProcessMetrics['boundType']) { + if (type === 'CPU Bound') return 'cpu'; + if (type === 'I/O Bound') return 'io'; + return 'balanced'; +} + +const MetricsTable = ({ metrics, processMap }: MetricsTableProps) => { + if (metrics.length === 0) { + return ( +
+ Nenhum processo concluído ainda. +
+ ); + } + + return ( +
+
+ + + + + + + + + + + + + + + + + {metrics.map((m, i) => { + const proc = processMap[m.processId]; + const name = proc?.name ?? m.processId; + const color = proc?.color ?? '#94a3b8'; + return ( + + + + + + + + + + + + + + ); + })} + +
ProcessoArrivalStartFinishResponseTurnaroundWaitingCPU TimeI/O TimeCPU%Bound
+
+ + {name} +
+
{m.arrivalTime}{m.startTick}{m.finishTick}{m.responseTime}{m.turnaroundTime}{m.waitingTime}{m.cpuTime}{m.ioTime}{m.cpuUtilization.toFixed(1)}% + {m.boundType} +
+
+ ); +}; + +export default MetricsTable; diff --git a/src/components/ProcessPanel/ProcessForm.tsx b/src/components/ProcessPanel/ProcessForm.tsx index a33ab59..ade9638 100644 --- a/src/components/ProcessPanel/ProcessForm.tsx +++ b/src/components/ProcessPanel/ProcessForm.tsx @@ -78,7 +78,7 @@ const ProcessForm = ({ editingProcess, onClose }: ProcessFormProps) => { if (bursts.at(-1)?.type !== 'cpu') errs.push('O último burst deve ser CPU'); bursts.forEach((b, i) => { if (b.duration < 1) errs.push(`Burst ${i + 1}: duração deve ser ≥ 1`); - if (i > 0 && bursts[i - 1].type === b.type) errs.push(`Bursts ${i} e ${i + 1} não podem ser do mesmo tipo (alternar CPU/IO)`); + if (i > 0 && bursts[i - 1]?.type === b.type) errs.push(`Bursts ${i} e ${i + 1} não podem ser do mesmo tipo (alternar CPU/IO)`); }); setErrors(errs); return errs.length === 0; diff --git a/src/components/SchedulerPanel/AlgorithmSelector.tsx b/src/components/SchedulerPanel/AlgorithmSelector.tsx index 3dbb718..a4c8d5a 100644 --- a/src/components/SchedulerPanel/AlgorithmSelector.tsx +++ b/src/components/SchedulerPanel/AlgorithmSelector.tsx @@ -1,15 +1,16 @@ import type { SchedulingAlgorithm } from '@core/types'; import { useProcessStore } from '../../store/processStore'; -const ALGORITHM_OPTIONS: { value: SchedulingAlgorithm; label: string }[] = [ +const ALGORITHM_OPTIONS: { value: SchedulingAlgorithm; label: string; disabled?: boolean }[] = [ { value: 'FCFS', label: 'FCFS — First Come First Served' }, { value: 'SJF_NP', label: 'SJF — Shortest Job First (NP)' }, { value: 'SJF_P', label: 'SRTF — Shortest Remaining Time (P)' }, { value: 'RR', label: 'RR — Round Robin' }, { value: 'PRIORITY_NP',label: 'Priority (Não-preemptivo)' }, { value: 'PRIORITY_P', label: 'Priority (Preemptivo)' }, + { value: 'PRIORITY_RR',label: 'Priority Round Robin (tempo real)' }, { value: 'HRRN', label: 'HRRN — Highest Response Ratio Next' }, - { value: 'MULTILEVEL', label: 'Multilevel Queue' }, + { value: 'MULTILEVEL', label: 'Multilevel Queue (em breve)', disabled: true }, ]; const AlgorithmSelector = () => { @@ -32,7 +33,7 @@ const AlgorithmSelector = () => { aria-label="Selecionar algoritmo de escalonamento" > {ALGORITHM_OPTIONS.map(opt => ( - ))} diff --git a/src/components/SchedulerPanel/SchedulerConfig.tsx b/src/components/SchedulerPanel/SchedulerConfig.tsx index 1e874a4..9515eb4 100644 --- a/src/components/SchedulerPanel/SchedulerConfig.tsx +++ b/src/components/SchedulerPanel/SchedulerConfig.tsx @@ -2,13 +2,13 @@ import { useProcessStore } from '../../store/processStore'; import type { SchedulingAlgorithm } from '@core/types'; // Algorithms that require a quantum parameter -const QUANTUM_ALGORITHMS = new Set(['RR', 'MULTILEVEL']); +const QUANTUM_ALGORITHMS = new Set(['RR', 'PRIORITY_RR', 'MULTILEVEL']); // Algorithms where aging (anti-starvation) is applicable -const AGING_ALGORITHMS = new Set(['PRIORITY_NP', 'PRIORITY_P']); +const AGING_ALGORITHMS = new Set(['PRIORITY_NP', 'PRIORITY_P', 'PRIORITY_RR']); // Algorithms that are preemptive by definition (used for read-only info only) -const PREEMPTIVE_BY_DESIGN = new Set(['SJF_P', 'RR', 'PRIORITY_P', 'MULTILEVEL']); +const PREEMPTIVE_BY_DESIGN = new Set(['SJF_P', 'RR', 'PRIORITY_P', 'PRIORITY_RR', 'MULTILEVEL']); const inputCls = 'w-full rounded-md bg-slate-700 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500'; const labelCls = 'block text-xs text-slate-400 mb-1'; diff --git a/src/components/shared/processUtils.ts b/src/components/shared/processUtils.ts index 24ec26d..b7e6ebd 100644 --- a/src/components/shared/processUtils.ts +++ b/src/components/shared/processUtils.ts @@ -28,5 +28,5 @@ const PALETTE = [ ]; let _paletteIndex = 0; export function nextColor(): string { - return PALETTE[_paletteIndex++ % PALETTE.length]; + return PALETTE[_paletteIndex++ % PALETTE.length]!; } diff --git a/src/core/__tests__/integration.test.ts b/src/core/__tests__/integration.test.ts new file mode 100644 index 0000000..ab00f85 --- /dev/null +++ b/src/core/__tests__/integration.test.ts @@ -0,0 +1,716 @@ +/** + * Integration Tests — Process Scheduler Simulator + * + * These tests validate end-to-end simulation correctness: processes go through + * the full lifecycle (New → Ready → Running → Waiting → Terminated), metrics + * are consistent, and each algorithm produces the expected scheduling behavior. + * + * Test IDs follow the spec (CT-05 onward, CT-01..CT-04 are in engine.test.ts). + */ + +import { describe, it, expect } from "vitest"; +import { SimulationEngine } from "../engine"; +import { computeProcessMetrics, computeGlobalMetrics } from "../metrics"; +import { presets } from "../presets"; +import type { Process, SchedulerConfig } from "../types"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const cfg = (overrides: Partial = {}): SchedulerConfig => ({ + algorithm: "FCFS", + quantum: 2, + contextSwitchTime: 0, + isPreemptive: false, + agingEnabled: false, + agingInterval: 5, + ...overrides, +}); + +const p = ( + id: string, + arrivalTime: number, + bursts: { type: "cpu" | "io"; duration: number }[], + priority = 5, +): Process => ({ + id, + name: id.toUpperCase(), + arrivalTime, + priority, + color: "#fff", + bursts, +}); + +/** Runs the engine and returns per-process metrics keyed by processId */ +function runAndGetMetrics(processes: Process[], config: SchedulerConfig) { + const engine = new SimulationEngine(processes, config); + engine.runAll(); + const rts = engine.runtimeStates; + const metricsMap = Object.fromEntries( + rts.map((rt) => [rt.processId, computeProcessMetrics(rt)]), + ); + const globalMetrics = computeGlobalMetrics( + Object.values(metricsMap), + engine.currentTick + 1, + ); + return { engine, metricsMap, globalMetrics }; +} + +// ─── CT-05: Convoy Effect (FCFS) ────────────────────────────────────────────── + +describe("CT-05: FCFS Convoy Effect", () => { + /** + * Large process P1 arrives first and blocks short processes P2, P3, P4. + * P1: arrival=0, CPU:12 + * P2: arrival=1, CPU:2 + * P3: arrival=2, CPU:1 + * P4: arrival=3, CPU:3 + * + * Expected Gantt: P1(0-11), P2(12-13), P3(14), P4(15-17) + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 12 }]), + p("p2", 1, [{ type: "cpu", duration: 2 }]), + p("p3", 2, [{ type: "cpu", duration: 1 }]), + p("p4", 3, [{ type: "cpu", duration: 3 }]), + ]; + + it("P1 (long process) runs uninterrupted for 12 ticks", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "FCFS" })); + const ticks = engine.runAll(); + for (let i = 0; i <= 11; i++) { + expect(ticks[i]?.cpuProcess).toBe("p1"); + } + }); + + it("short processes execute in arrival order after P1 finishes", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "FCFS" })); + const ticks = engine.runAll(); + expect(ticks[12]?.cpuProcess).toBe("p2"); + expect(ticks[13]?.cpuProcess).toBe("p2"); + expect(ticks[14]?.cpuProcess).toBe("p3"); + expect(ticks[15]?.cpuProcess).toBe("p4"); + }); + + it("P2, P3, P4 suffer high waiting times (convoy effect)", () => { + const { metricsMap } = runAndGetMetrics(processes, cfg({ algorithm: "FCFS" })); + // P2: waited ticks 1-11 = 11 ticks + expect(metricsMap["p2"]!.waitingTime).toBe(11); + // P3: waited ticks 2-13 = 12 ticks + expect(metricsMap["p3"]!.waitingTime).toBe(12); + // P4: waited ticks 3-14 = 12 ticks + expect(metricsMap["p4"]!.waitingTime).toBe(12); + }); + + it("SJF-NP dramatically reduces average waiting time for the same input", () => { + const { globalMetrics: fcfsGlobal } = runAndGetMetrics(processes, cfg({ algorithm: "FCFS" })); + const { globalMetrics: sjfGlobal } = runAndGetMetrics(processes, cfg({ algorithm: "SJF_NP" })); + + // SJF should yield much lower average waiting time than FCFS (convoy effect) + expect(sjfGlobal.avgWaitingTime).toBeLessThan(fcfsGlobal.avgWaitingTime); + }); +}); + +// ─── CT-06: SJF Non-Preemptive ordering ─────────────────────────────────────── + +describe("CT-06: SJF Non-Preemptive ordering", () => { + /** + * All processes arrive at tick 0. SJF picks shortest first. + * P1: CPU:5, P2: CPU:2, P3: CPU:1, P4: CPU:3 + * Expected order: P3(CPU:1) → P2(CPU:2) → P4(CPU:3) → P1(CPU:5) + * Gantt: P3(0-0), P2(1-2), P4(3-5), P1(6-10) + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 5 }]), + p("p2", 0, [{ type: "cpu", duration: 2 }]), + p("p3", 0, [{ type: "cpu", duration: 1 }]), + p("p4", 0, [{ type: "cpu", duration: 3 }]), + ]; + + it("selects processes in ascending burst duration order", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "SJF_NP" })); + const ticks = engine.runAll(); + + // P3 (CPU:1) first + expect(ticks[0]?.cpuProcess).toBe("p3"); + // P2 (CPU:2) second + expect(ticks[1]?.cpuProcess).toBe("p2"); + expect(ticks[2]?.cpuProcess).toBe("p2"); + // P4 (CPU:3) third + expect(ticks[3]?.cpuProcess).toBe("p4"); + expect(ticks[4]?.cpuProcess).toBe("p4"); + expect(ticks[5]?.cpuProcess).toBe("p4"); + // P1 (CPU:5) last + expect(ticks[6]?.cpuProcess).toBe("p1"); + }); + + it("P3 has zero waiting time (runs first)", () => { + const { metricsMap } = runAndGetMetrics(processes, cfg({ algorithm: "SJF_NP" })); + expect(metricsMap["p3"]!.waitingTime).toBe(0); + expect(metricsMap["p3"]!.responseTime).toBe(0); + }); + + it("produces optimal average waiting time (SJF is optimal for non-preemptive)", () => { + const { globalMetrics } = runAndGetMetrics(processes, cfg({ algorithm: "SJF_NP" })); + // Avg waiting: (0 + 1 + 3 + 6) / 4 = 10/4 = 2.5 + expect(globalMetrics.avgWaitingTime).toBeCloseTo(2.5, 5); + }); +}); + +// ─── CT-07: Priority NP Starvation ─────────────────────────────────────────── + +describe("CT-07: Priority NP — P5 starves until all high-priority processes finish", () => { + /** + * P1-P4 have priority 1 (highest). P5 has priority 10 (lowest). + * P5 arrives first but never gets CPU until all P1-P4 finish. + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 3 }], 1), + p("p2", 1, [{ type: "cpu", duration: 3 }], 1), + p("p3", 2, [{ type: "cpu", duration: 3 }], 1), + p("p4", 3, [{ type: "cpu", duration: 3 }], 1), + p("p5", 0, [{ type: "cpu", duration: 5 }], 10), + ]; + + it("P5 does not run while any high-priority process is in the ready queue", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "PRIORITY_NP" })); + const ticks = engine.runAll(); + // Ticks 0-11: only P1-P4 should be on CPU + for (let i = 0; i <= 11; i++) { + expect(ticks[i]?.cpuProcess).not.toBe("p5"); + } + }); + + it("P5 starts running only after all high-priority processes finish", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "PRIORITY_NP" })); + const ticks = engine.runAll(); + // P4 finishes at tick 12 (runs 9-11); P5 starts at 12 + expect(ticks[12]?.cpuProcess).toBe("p5"); + }); + + it("P5 has the highest waiting time", () => { + const { metricsMap } = runAndGetMetrics(processes, cfg({ algorithm: "PRIORITY_NP" })); + const p5Waiting = metricsMap["p5"]!.waitingTime; + // P5 waited from tick 0 until tick 12 → waiting = 12 + expect(p5Waiting).toBe(12); + // P5 has more waiting time than all others combined + const others = ["p1", "p2", "p3", "p4"].map((id) => metricsMap[id]!.waitingTime); + expect(p5Waiting).toBeGreaterThan(Math.max(...others)); + }); +}); + +// ─── CT-08: Aging prevents starvation ──────────────────────────────────────── + +describe("CT-08: Priority Preemptive with aging — P5 eventually runs earlier", () => { + /** + * Same scenario as CT-07 but with aging enabled (agingInterval=3). + * P5 should have a lower waiting time than in CT-07 because aging + * eventually promotes it to run before all high-priority processes finish. + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 3 }], 1), + p("p2", 1, [{ type: "cpu", duration: 3 }], 1), + p("p3", 2, [{ type: "cpu", duration: 3 }], 1), + p("p4", 3, [{ type: "cpu", duration: 3 }], 1), + p("p5", 0, [{ type: "cpu", duration: 5 }], 10), + ]; + + const agingConfig = cfg({ + algorithm: "PRIORITY_P", + isPreemptive: true, + agingEnabled: true, + agingInterval: 3, + }); + + const noAgingConfig = cfg({ algorithm: "PRIORITY_NP" }); + + it("all processes eventually terminate with aging enabled", () => { + const engine = new SimulationEngine(processes, agingConfig); + engine.runAll(); + expect(engine.isFinished).toBe(true); + const rts = engine.runtimeStates; + for (const rt of rts) { + expect(rt.state).toBe("Terminated"); + } + }); + + it("P5 waiting time with aging is less than or equal to without aging", () => { + const { metricsMap: withAging } = runAndGetMetrics(processes, agingConfig); + const { metricsMap: withoutAging } = runAndGetMetrics(processes, noAgingConfig); + expect(withAging["p5"]!.waitingTime).toBeLessThanOrEqual(withoutAging["p5"]!.waitingTime); + }); +}); + +// ─── CT-09: Multiple I/O Cycles ────────────────────────────────────────────── + +describe("CT-09: Process with multiple CPU/I/O cycles", () => { + /** + * P1 alternates: CPU:2 → IO:3 → CPU:1 → IO:2 → CPU:3 + * This validates state machine across multiple burst transitions. + */ + const processes = [ + p("p1", 0, [ + { type: "cpu", duration: 2 }, + { type: "io", duration: 3 }, + { type: "cpu", duration: 1 }, + { type: "io", duration: 2 }, + { type: "cpu", duration: 3 }, + ]), + ]; + + it("traverses all states in correct order", () => { + const engine = new SimulationEngine(processes, cfg()); + const ticks = engine.runAll(); + const states = ticks.map((t) => t.states["p1"]); + + expect(states[0]).toBe("Running"); // CPU:2 ticks 0-1 + expect(states[1]).toBe("Running"); + expect(states[2]).toBe("Waiting"); // IO:3 ticks 2-4 + expect(states[3]).toBe("Waiting"); + expect(states[4]).toBe("Waiting"); + expect(states[5]).toBe("Running"); // CPU:1 tick 5 + expect(states[6]).toBe("Waiting"); // IO:2 ticks 6-7 + expect(states[7]).toBe("Waiting"); + expect(states[8]).toBe("Running"); // CPU:3 ticks 8-10 + expect(states[9]).toBe("Running"); + expect(states[10]).toBe("Running"); + }); + + it("finishes at the correct tick", () => { + const engine = new SimulationEngine(processes, cfg()); + engine.runAll(); + const rt = engine.runtimeStates[0]!; + // Total duration: 2+3+1+2+3 = 11 ticks → finishTick = 11 + expect(rt.finishTick).toBe(11); + }); + + it("accumulates correct cpu and io times", () => { + const engine = new SimulationEngine(processes, cfg()); + engine.runAll(); + const rt = engine.runtimeStates[0]!; + expect(rt.cpuTime).toBe(6); // 2+1+3 + expect(rt.ioTime).toBe(5); // 3+2 + }); + + it("metrics: turnaround = cpuTime + ioTime + waitingTime", () => { + const { metricsMap } = runAndGetMetrics(processes, cfg()); + const m = metricsMap["p1"]!; + // For a single process, waiting time should be 0 (never in Ready queue) + expect(m.waitingTime).toBe(0); + expect(m.turnaroundTime).toBe(m.cpuTime + m.ioTime + m.waitingTime); + }); +}); + +// ─── CT-10: Simultaneous arrivals — tie-breaking ────────────────────────────── + +describe("CT-10: Simultaneous arrivals — tie-breaking by ID", () => { + /** + * All 4 processes arrive at tick 0 with equal priority and burst duration. + * FCFS tie-break: by processId lexicographic order → p1, p2, p3, p4. + */ + const processes = [ + p("p4", 0, [{ type: "cpu", duration: 2 }]), + p("p2", 0, [{ type: "cpu", duration: 2 }]), + p("p1", 0, [{ type: "cpu", duration: 2 }]), + p("p3", 0, [{ type: "cpu", duration: 2 }]), + ]; + + it("executes processes in lexicographic ID order on tie", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "FCFS" })); + const ticks = engine.runAll(); + + expect(ticks[0]?.cpuProcess).toBe("p1"); + expect(ticks[1]?.cpuProcess).toBe("p1"); + expect(ticks[2]?.cpuProcess).toBe("p2"); + expect(ticks[3]?.cpuProcess).toBe("p2"); + expect(ticks[4]?.cpuProcess).toBe("p3"); + expect(ticks[5]?.cpuProcess).toBe("p3"); + expect(ticks[6]?.cpuProcess).toBe("p4"); + expect(ticks[7]?.cpuProcess).toBe("p4"); + }); + + it("first process (p1) has response time 0 and zero waiting time", () => { + const { metricsMap } = runAndGetMetrics(processes, cfg({ algorithm: "FCFS" })); + expect(metricsMap["p1"]!.responseTime).toBe(0); + expect(metricsMap["p1"]!.waitingTime).toBe(0); + }); +}); + +// ─── CT-11: CPU idle gap between arrivals ───────────────────────────────────── + +describe("CT-11: CPU idle gap between process batches", () => { + /** + * P1 finishes early; CPU becomes idle until P2 arrives later. + * P1: arrival=0, CPU:2 + * P2: arrival=6, CPU:2 + * Gap: ticks 2-5 (CPU idle for 4 ticks) + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 2 }]), + p("p2", 6, [{ type: "cpu", duration: 2 }]), + ]; + + it("CPU is idle during the gap (no cpuProcess)", () => { + const engine = new SimulationEngine(processes, cfg()); + const ticks = engine.runAll(); + // ticks 2,3,4,5: CPU should be idle + for (let i = 2; i <= 5; i++) { + expect(ticks[i]?.cpuProcess).toBeNull(); + } + }); + + it("P2 starts immediately on arrival", () => { + const engine = new SimulationEngine(processes, cfg()); + const ticks = engine.runAll(); + expect(ticks[6]?.cpuProcess).toBe("p2"); + }); + + it("P2 has zero waiting time (no competition)", () => { + const { metricsMap } = runAndGetMetrics(processes, cfg()); + expect(metricsMap["p2"]!.waitingTime).toBe(0); + expect(metricsMap["p2"]!.responseTime).toBe(0); + }); + + it("global CPU utilization reflects idle period", () => { + const { globalMetrics } = runAndGetMetrics(processes, cfg()); + // Total CPU used: 4 ticks out of 8 ticks = 50% + expect(globalMetrics.cpuUtilization).toBeCloseTo(50, 5); + }); +}); + +// ─── CT-12: Round Robin with quantum=1 ─────────────────────────────────────── + +describe("CT-12: Round Robin quantum=1 — strict rotation", () => { + /** + * P1(CPU:3), P2(CPU:2), P3(CPU:1) all arrive at tick 0, quantum=1. + * Each tick a different process runs in round-robin order. + * Expected: P1(0), P2(1), P3(2), P1(3), P2(4), P1(5) + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 3 }]), + p("p2", 0, [{ type: "cpu", duration: 2 }]), + p("p3", 0, [{ type: "cpu", duration: 1 }]), + ]; + + it("rotates one tick per process", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "RR", quantum: 1 })); + const ticks = engine.runAll(); + + expect(ticks[0]?.cpuProcess).toBe("p1"); + expect(ticks[1]?.cpuProcess).toBe("p2"); + expect(ticks[2]?.cpuProcess).toBe("p3"); + expect(ticks[3]?.cpuProcess).toBe("p1"); + expect(ticks[4]?.cpuProcess).toBe("p2"); + expect(ticks[5]?.cpuProcess).toBe("p1"); + }); + + it("all processes finish at expected ticks", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "RR", quantum: 1 })); + engine.runAll(); + const rts = engine.runtimeStates; + const rt1 = rts.find((r) => r.processId === "p1")!; + const rt2 = rts.find((r) => r.processId === "p2")!; + const rt3 = rts.find((r) => r.processId === "p3")!; + + expect(rt3.finishTick).toBe(3); // P3 finishes after 1 run at tick 2 + expect(rt2.finishTick).toBe(5); // P2 finishes after 2 runs + expect(rt1.finishTick).toBe(6); // P1 finishes after 3 runs + }); +}); + +// ─── CT-13: Context switch overhead ────────────────────────────────────────── + +describe("CT-13: Context switch overhead with multiple algorithms", () => { + /** + * Each process switch incurs 1 idle tick (contextSwitchTime=1). + * Tests that context switch overhead is correctly accounted for + * in multiple algorithm contexts. + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 2 }]), + p("p2", 0, [{ type: "cpu", duration: 2 }]), + p("p3", 0, [{ type: "cpu", duration: 2 }]), + ]; + + it("FCFS: inserts 2 context switch ticks for 3 processes", () => { + const engine = new SimulationEngine( + processes, + cfg({ algorithm: "FCFS", contextSwitchTime: 1 }), + ); + const ticks = engine.runAll(); + + // P1: 0-1, ctx: 2, P2: 3-4, ctx: 5, P3: 6-7 + expect(ticks[2]?.contextSwitching).toBe(true); + expect(ticks[2]?.cpuProcess).toBeNull(); + expect(ticks[5]?.contextSwitching).toBe(true); + expect(ticks[5]?.cpuProcess).toBeNull(); + expect(ticks.length).toBe(8); + }); + + it("total simulation time is longer with context switch", () => { + const withCtx = new SimulationEngine(processes, cfg({ algorithm: "FCFS", contextSwitchTime: 1 })); + const withoutCtx = new SimulationEngine(processes, cfg({ algorithm: "FCFS", contextSwitchTime: 0 })); + withCtx.runAll(); + withoutCtx.runAll(); + + // With 2 context switches of 1 tick each: +2 ticks total + expect(withCtx.currentTick).toBe(withoutCtx.currentTick + 2); + }); + + it("context switch ticks are not counted as cpu time for any process", () => { + const engine = new SimulationEngine( + processes, + cfg({ algorithm: "FCFS", contextSwitchTime: 1 }), + ); + engine.runAll(); + const totalCpuTime = engine.runtimeStates.reduce((s, rt) => s + rt.cpuTime, 0); + // 3 processes × 2 cpu ticks each = 6 + expect(totalCpuTime).toBe(6); + }); +}); + +// ─── CT-14: HRRN fairness — waited process wins over fresh arrivals ─────────── + +describe("CT-14: HRRN — long wait compensates for longer burst", () => { + /** + * P1(CPU:1) runs first (only one ready at tick 0). + * When P1 finishes at tick 1, P2(CPU:6, waited=1) and P3(CPU:2, waited=0) are both ready. + * + * HRRN ratios at tick 1: + * P2: (1 + 6) / 6 = 1.167 + * P3: (0 + 2) / 2 = 1.0 + * + * P2 wins because it has been waiting. + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 1 }]), + p("p2", 0, [{ type: "cpu", duration: 6 }]), + p("p3", 1, [{ type: "cpu", duration: 2 }]), + ]; + + it("P2 wins over fresh P3 due to higher response ratio", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "HRRN" })); + const ticks = engine.runAll(); + // tick 1: P2 starts (ratio 1.167 > P3 ratio 1.0) + expect(ticks[1]?.cpuProcess).toBe("p2"); + }); + + it("P3 runs only after P2 finishes", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "HRRN" })); + const ticks = engine.runAll(); + // P2 runs ticks 1-6; P3 starts at tick 7 + expect(ticks[7]?.cpuProcess).toBe("p3"); + }); +}); + +// ─── CT-15: SRTF — preemption on arrival with complex I/O ──────────────────── + +describe("CT-15: SRTF preemption with I/O processes", () => { + /** + * P1(CPU:5,IO:2,CPU:2) starts at 0. + * P2(CPU:2) arrives at tick 2 — preempts P1 (remaining=3 > 2). + * While P2 runs, P1 goes to ready queue. + * P2 finishes at tick 4; P1 resumes its CPU burst. + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 5 }, { type: "io", duration: 2 }, { type: "cpu", duration: 2 }]), + p("p2", 2, [{ type: "cpu", duration: 2 }]), + ]; + + it("P2 preempts P1 at tick 2", () => { + const engine = new SimulationEngine( + processes, + cfg({ algorithm: "SJF_P", isPreemptive: true }), + ); + const ticks = engine.runAll(); + expect(ticks[0]?.cpuProcess).toBe("p1"); + expect(ticks[1]?.cpuProcess).toBe("p1"); + expect(ticks[2]?.cpuProcess).toBe("p2"); // preemption here + expect(ticks[3]?.cpuProcess).toBe("p2"); + }); + + it("P1 eventually completes all burst phases and terminates", () => { + const engine = new SimulationEngine( + processes, + cfg({ algorithm: "SJF_P", isPreemptive: true }), + ); + engine.runAll(); + expect(engine.isFinished).toBe(true); + const p1rt = engine.runtimeStates.find((r) => r.processId === "p1")!; + expect(p1rt.state).toBe("Terminated"); + expect(p1rt.ioTime).toBe(2); + }); +}); + +// ─── CT-16: Metrics invariant across all algorithms ────────────────────────── + +describe("CT-16: Metrics invariant — turnaround = cpuTime + ioTime + waitingTime", () => { + /** + * For any completed process: + * turnaround = cpuTime + ioTime + waitingTime + * + * This invariant must hold for every algorithm. + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 4 }, { type: "io", duration: 2 }, { type: "cpu", duration: 2 }]), + p("p2", 1, [{ type: "cpu", duration: 3 }]), + p("p3", 2, [{ type: "cpu", duration: 1 }, { type: "io", duration: 3 }, { type: "cpu", duration: 2 }]), + ]; + + const algorithms: SchedulerConfig["algorithm"][] = [ + "FCFS", "SJF_NP", "SJF_P", "RR", "PRIORITY_NP", "PRIORITY_P", "HRRN", + ]; + + for (const algorithm of algorithms) { + it(`holds for ${algorithm}`, () => { + const isPreemptive = algorithm === "SJF_P" || algorithm === "PRIORITY_P"; + const { metricsMap } = runAndGetMetrics( + processes, + cfg({ algorithm, isPreemptive, quantum: 2 }), + ); + for (const [id, m] of Object.entries(metricsMap)) { + expect(m.turnaroundTime, `${algorithm}: ${id} turnaround invariant`).toBeCloseTo( + m.cpuTime + m.ioTime + m.waitingTime, + 5, + ); + } + }); + } +}); + +// ─── CT-17: All processes always terminate ──────────────────────────────────── + +describe("CT-17: All processes eventually terminate (no infinite loops)", () => { + /** + * For every supported algorithm, a simulation must terminate. + * This validates the engine has no deadlocks or infinite scheduling loops. + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 3 }, { type: "io", duration: 2 }, { type: "cpu", duration: 2 }]), + p("p2", 0, [{ type: "cpu", duration: 5 }]), + p("p3", 2, [{ type: "cpu", duration: 1 }]), + p("p4", 3, [{ type: "cpu", duration: 4 }, { type: "io", duration: 1 }, { type: "cpu", duration: 1 }]), + ]; + + const algorithmsWithConfig: [string, SchedulerConfig][] = [ + ["FCFS", cfg({ algorithm: "FCFS" })], + ["SJF_NP", cfg({ algorithm: "SJF_NP" })], + ["SJF_P", cfg({ algorithm: "SJF_P", isPreemptive: true })], + ["RR q=2", cfg({ algorithm: "RR", quantum: 2 })], + ["RR q=1", cfg({ algorithm: "RR", quantum: 1 })], + ["PRIORITY_NP", cfg({ algorithm: "PRIORITY_NP" })], + ["PRIORITY_P", cfg({ algorithm: "PRIORITY_P", isPreemptive: true })], + ["PRIORITY_P+aging", cfg({ algorithm: "PRIORITY_P", isPreemptive: true, agingEnabled: true, agingInterval: 3 })], + ["HRRN", cfg({ algorithm: "HRRN" })], + ]; + + for (const [name, config] of algorithmsWithConfig) { + it(`${name}: all processes reach Terminated state`, () => { + const engine = new SimulationEngine(processes, config); + engine.runAll(); + expect(engine.isFinished).toBe(true); + for (const rt of engine.runtimeStates) { + expect(rt.state, `${name}: process ${rt.processId} should be Terminated`).toBe("Terminated"); + } + }); + } +}); + +// ─── CT-18: Preset validation ───────────────────────────────────────────────── + +describe("CT-18: All presets complete successfully", () => { + /** + * Each preset must run to completion with all processes terminated, + * and produce valid (non-negative) metrics. + */ + for (const preset of presets) { + it(`preset "${preset.name}" terminates and yields valid metrics`, () => { + const engine = new SimulationEngine(preset.processes, preset.config); + engine.runAll(); + + expect(engine.isFinished).toBe(true); + + for (const rt of engine.runtimeStates) { + expect(rt.state).toBe("Terminated"); + expect(rt.finishTick).not.toBeNull(); + expect(rt.startTick).not.toBeNull(); + + const m = computeProcessMetrics(rt); + expect(m.turnaroundTime).toBeGreaterThan(0); + expect(m.waitingTime).toBeGreaterThanOrEqual(0); + expect(m.responseTime).toBeGreaterThanOrEqual(0); + expect(m.cpuTime).toBeGreaterThan(0); + expect(m.ioTime).toBeGreaterThanOrEqual(0); + // Invariant: turnaround = cpuTime + ioTime + waitingTime + expect(m.turnaroundTime).toBeCloseTo(m.cpuTime + m.ioTime + m.waitingTime, 5); + } + }); + } +}); + +// ─── CT-19: RR with I/O — quantum resets correctly after I/O return ─────────── + +describe("CT-19: RR with I/O — process returning from I/O gets fresh quantum", () => { + /** + * P1(CPU:4, IO:2, CPU:2) with quantum=2. + * P2(CPU:6) with quantum=2. + * + * P1 uses 2 CPU ticks (quantum exhausted) → goes to ready. + * P2 uses 2 ticks → goes to ready. + * P1 uses last 2 CPU ticks → goes to I/O. + * While P1 does I/O, P2 runs. + * P1 returns from I/O and gets a fresh quantum for its final 2 CPU ticks. + */ + const processes = [ + p("p1", 0, [{ type: "cpu", duration: 4 }, { type: "io", duration: 2 }, { type: "cpu", duration: 2 }]), + p("p2", 0, [{ type: "cpu", duration: 6 }]), + ]; + + it("P1 and P2 both terminate", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "RR", quantum: 2 })); + engine.runAll(); + expect(engine.isFinished).toBe(true); + for (const rt of engine.runtimeStates) { + expect(rt.state).toBe("Terminated"); + } + }); + + it("P1 accumulates correct cpu and io times", () => { + const engine = new SimulationEngine(processes, cfg({ algorithm: "RR", quantum: 2 })); + engine.runAll(); + const p1rt = engine.runtimeStates.find((r) => r.processId === "p1")!; + expect(p1rt.cpuTime).toBe(6); // 4 + 2 + expect(p1rt.ioTime).toBe(2); + }); + + it("metrics invariant holds for both processes", () => { + const { metricsMap } = runAndGetMetrics(processes, cfg({ algorithm: "RR", quantum: 2 })); + for (const m of Object.values(metricsMap)) { + expect(m.turnaroundTime).toBeCloseTo(m.cpuTime + m.ioTime + m.waitingTime, 5); + } + }); +}); + +// ─── CT-20: Single process — no competition edge case ──────────────────────── + +describe("CT-20: Single process — baseline edge case", () => { + /** + * With only one process, there is no scheduling decision to make. + * The process should run uninterrupted and have zero waiting time, + * regardless of algorithm. + */ + const process = p("p1", 0, [ + { type: "cpu", duration: 3 }, + { type: "io", duration: 2 }, + { type: "cpu", duration: 2 }, + ]); + + const algorithms: SchedulerConfig["algorithm"][] = ["FCFS", "SJF_NP", "RR", "PRIORITY_NP", "HRRN"]; + + for (const algorithm of algorithms) { + it(`${algorithm}: single process has zero waiting time`, () => { + const { metricsMap } = runAndGetMetrics([process], cfg({ algorithm, quantum: 2 })); + expect(metricsMap["p1"]!.waitingTime).toBe(0); + expect(metricsMap["p1"]!.responseTime).toBe(0); + }); + } +}); diff --git a/src/core/__tests__/priority_rr.test.ts b/src/core/__tests__/priority_rr.test.ts new file mode 100644 index 0000000..3f4f2fb --- /dev/null +++ b/src/core/__tests__/priority_rr.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from "vitest"; +import { priorityRr } from "../algorithms/priority_rr"; +import { SimulationEngine } from "../engine"; +import type { Process, ProcessRuntime, SchedulerConfig } from "../types"; + +// ---------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------- + +function makeRt( + id: string, + priority: number, + quantumRemaining = 2, +): ProcessRuntime { + return { + processId: id, + state: "Ready", + remainingBurst: 4, + burstIndex: 0, + arrivalTick: 0, + startTick: null, + finishTick: null, + waitingTime: 0, + cpuTime: 0, + ioTime: 0, + responseTime: null, + quantumRemaining, + currentPriority: priority, + }; +} + +const cfg = (quantum = 2): SchedulerConfig => ({ + algorithm: "PRIORITY_RR", + quantum, + contextSwitchTime: 0, + isPreemptive: true, + agingEnabled: false, + agingInterval: 5, +}); + +const p = ( + id: string, + arrival: number, + cpuDuration: number, + priority = 5, +): Process => ({ + id, + name: id, + arrivalTime: arrival, + priority, + color: "#fff", + bursts: [{ type: "cpu", duration: cpuDuration }], +}); + +// ---------------------------------------------------------------- +// Unit: select() +// ---------------------------------------------------------------- + +describe("PRIORITY_RR select()", () => { + it("returns null for empty queue", () => { + expect(priorityRr.select([], cfg())).toBeNull(); + }); + + it("picks the process with the lowest priority number (highest priority)", () => { + const high = makeRt("high", 1); + const low = makeRt("low", 5); + expect(priorityRr.select([low, high], cfg())?.processId).toBe("high"); + expect(priorityRr.select([high, low], cfg())?.processId).toBe("high"); + }); + + it("picks the FIRST in insertion order when priorities are equal (FIFO = RR)", () => { + const a = makeRt("a", 3); + const b = makeRt("b", 3); + const c = makeRt("c", 3); + expect(priorityRr.select([a, b, c], cfg())?.processId).toBe("a"); + expect(priorityRr.select([b, c, a], cfg())?.processId).toBe("b"); + }); + + it("ignores lower-priority processes when a higher-priority one is present", () => { + const critical = makeRt("critical", 1); + const normal1 = makeRt("n1", 5); + const normal2 = makeRt("n2", 5); + expect( + priorityRr.select([normal1, normal2, critical], cfg())?.processId, + ).toBe("critical"); + }); +}); + +// ---------------------------------------------------------------- +// Unit: shouldPreempt() +// ---------------------------------------------------------------- + +describe("PRIORITY_RR shouldPreempt()", () => { + it("preempts when candidate has strictly higher priority", () => { + const running = makeRt("current", 5); + const higher = makeRt("higher", 1); + expect(priorityRr.shouldPreempt(running, higher, cfg())).toBe(true); + }); + + it("does NOT preempt for same priority (quantum handles rotation)", () => { + const a = makeRt("a", 3); + const b = makeRt("b", 3); + expect(priorityRr.shouldPreempt(a, b, cfg())).toBe(false); + }); + + it("does NOT preempt when candidate has lower priority", () => { + const running = makeRt("current", 1); + const lower = makeRt("lower", 5); + expect(priorityRr.shouldPreempt(running, lower, cfg())).toBe(false); + }); +}); + +// ---------------------------------------------------------------- +// Unit: isQuantumExpired() +// ---------------------------------------------------------------- + +describe("PRIORITY_RR isQuantumExpired()", () => { + it("returns true when quantumRemaining <= 0", () => { + expect(priorityRr.isQuantumExpired(makeRt("p", 5, 0), cfg())).toBe(true); + expect(priorityRr.isQuantumExpired(makeRt("p", 5, -1), cfg())).toBe(true); + }); + + it("returns false when quantum is not exhausted", () => { + expect(priorityRr.isQuantumExpired(makeRt("p", 5, 1), cfg())).toBe(false); + expect(priorityRr.isQuantumExpired(makeRt("p", 5, 2), cfg())).toBe(false); + }); +}); + +// ---------------------------------------------------------------- +// Integration: Round-robin within the same priority band +// ---------------------------------------------------------------- + +describe("PRIORITY_RR integration — RR within same priority", () => { + it("two equal-priority processes rotate via quantum (CT-PRIORITY_RR-01)", () => { + // P1 and P2 both priority=2, CPU:4 each, quantum=2 + // Expected Gantt: P1(0-2), P2(2-4), P1(4-6), P2(6-8) + const processes = [p("p1", 0, 4, 2), p("p2", 0, 4, 2)]; + const engine = new SimulationEngine(processes, cfg(2)); + const ticks = engine.runAll(); + const cpu = ticks.map((t) => t.cpuProcess); + + expect(cpu).toHaveLength(8); + expect(cpu[0]).toBe("p1"); + expect(cpu[1]).toBe("p1"); + expect(cpu[2]).toBe("p2"); + expect(cpu[3]).toBe("p2"); + expect(cpu[4]).toBe("p1"); + expect(cpu[5]).toBe("p1"); + expect(cpu[6]).toBe("p2"); + expect(cpu[7]).toBe("p2"); + }); + + it("three equal-priority processes rotate in arrival order", () => { + // P1(CPU:2), P2(CPU:2), P3(CPU:2), quantum=2 — each finishes in first quantum + const processes = [p("p1", 0, 2, 3), p("p2", 0, 2, 3), p("p3", 0, 2, 3)]; + const engine = new SimulationEngine(processes, cfg(2)); + const ticks = engine.runAll(); + const cpu = ticks.map((t) => t.cpuProcess); + + expect(cpu).toHaveLength(6); + expect(cpu[0]).toBe("p1"); + expect(cpu[1]).toBe("p1"); + expect(cpu[2]).toBe("p2"); + expect(cpu[3]).toBe("p2"); + expect(cpu[4]).toBe("p3"); + expect(cpu[5]).toBe("p3"); + }); +}); + +// ---------------------------------------------------------------- +// Integration: Priority preemption +// ---------------------------------------------------------------- + +describe("PRIORITY_RR integration — priority preemption", () => { + it("high-priority process arriving mid-run immediately preempts (CT-PRIORITY_RR-02)", () => { + // P1: priority=3, arrival=0, CPU:6 (large quantum so it won't expire) + // P2: priority=1, arrival=2, CPU:2 + // Expected: P1(0-1), P2 preempts at tick 2, P2(2-3), P1 resumes(4-7) + const processes = [p("p1", 0, 6, 3), p("p2", 2, 2, 1)]; + const engine = new SimulationEngine(processes, cfg(10)); + const ticks = engine.runAll(); + const cpu = ticks.map((t) => t.cpuProcess); + + // P1 runs ticks 0, 1; P2 preempts at tick 2; P1 resumes after P2 finishes + expect(cpu[0]).toBe("p1"); + expect(cpu[1]).toBe("p1"); + expect(cpu[2]).toBe("p2"); // preempted here + expect(cpu[3]).toBe("p2"); + // After P2 terminates, P1 resumes + expect(cpu[4]).toBe("p1"); + expect(cpu[5]).toBe("p1"); + expect(cpu[6]).toBe("p1"); + expect(cpu[7]).toBe("p1"); + expect(cpu).toHaveLength(8); + }); + + it("lower-priority process cannot preempt a higher-priority running process", () => { + const processes = [p("p1", 0, 4, 1), p("p2", 1, 4, 5)]; + const engine = new SimulationEngine(processes, cfg(10)); + const ticks = engine.runAll(); + const cpu = ticks.map((t) => t.cpuProcess); + + // P1 (priority=1) runs entirely first; P2 waits + expect(cpu.slice(0, 4).every((id) => id === "p1")).toBe(true); + expect(cpu.slice(4).every((id) => id === "p2")).toBe(true); + }); +}); + +// ---------------------------------------------------------------- +// Integration: Combined — preemption + RR (preset scenario) +// ---------------------------------------------------------------- + +describe("PRIORITY_RR integration — combined preemption + RR", () => { + it("preset scenario: P1 & P2 (prio=2) RR, P3 (prio=1) preempts at arrival", () => { + // P1: prio=2, arrival=0, CPU:4 + // P2: prio=2, arrival=0, CPU:4 + // P3: prio=1, arrival=4, CPU:2 — arrives just as P2's first quantum ends + // quantum=2 + // Expected Gantt: P1(0-2), P2(2-4), P3(4-6), P1(6-8), P2(8-10) + const processes = [p("p1", 0, 4, 2), p("p2", 0, 4, 2), p("p3", 4, 2, 1)]; + const engine = new SimulationEngine(processes, cfg(2)); + const ticks = engine.runAll(); + const cpu = ticks.map((t) => t.cpuProcess); + + expect(cpu[0]).toBe("p1"); + expect(cpu[1]).toBe("p1"); + expect(cpu[2]).toBe("p2"); + expect(cpu[3]).toBe("p2"); + expect(cpu[4]).toBe("p3"); // P3 preempts (or takes CPU when it's free) + expect(cpu[5]).toBe("p3"); + expect(cpu[6]).toBe("p1"); + expect(cpu[7]).toBe("p1"); + expect(cpu[8]).toBe("p2"); + expect(cpu[9]).toBe("p2"); + expect(cpu).toHaveLength(10); + }); + + it("all processes terminate correctly", () => { + const processes = [p("p1", 0, 4, 2), p("p2", 0, 4, 2), p("p3", 4, 2, 1)]; + const engine = new SimulationEngine(processes, cfg(2)); + engine.runAll(); + const rts = engine.runtimeStates; + expect(rts.every((rt) => rt.state === "Terminated")).toBe(true); + expect(rts.find((r) => r.processId === "p1")!.cpuTime).toBe(4); + expect(rts.find((r) => r.processId === "p2")!.cpuTime).toBe(4); + expect(rts.find((r) => r.processId === "p3")!.cpuTime).toBe(2); + }); +}); diff --git a/src/core/algorithms/index.ts b/src/core/algorithms/index.ts index a596f29..ab7b801 100644 --- a/src/core/algorithms/index.ts +++ b/src/core/algorithms/index.ts @@ -3,6 +3,7 @@ import { fcfs } from "./fcfs"; import { sjfNp, srtf } from "./sjf"; import { rr } from "./rr"; import { priorityNp, priorityP } from "./priority"; +import { priorityRr } from "./priority_rr"; import { hrrn } from "./hrrn"; import { multilevel } from "./multilevel"; @@ -54,6 +55,7 @@ export const algorithms: Record = { RR: rr, PRIORITY_NP: priorityNp, PRIORITY_P: priorityP, + PRIORITY_RR: priorityRr, HRRN: hrrn, MULTILEVEL: multilevel, }; diff --git a/src/core/algorithms/priority_rr.ts b/src/core/algorithms/priority_rr.ts new file mode 100644 index 0000000..82b42a7 --- /dev/null +++ b/src/core/algorithms/priority_rr.ts @@ -0,0 +1,49 @@ +import type { SchedulerAlgorithm } from "./index"; + +/** + * Priority Round Robin (PRIORITY_RR) + * + * Mimics real-OS scheduling (Windows priority bands, POSIX SCHED_RR): + * + * - The ready queue is conceptually divided into priority bands. + * - The scheduler always dispatches from the highest-priority band + * (lowest numeric value), leaving lower-priority processes waiting. + * - Within the same priority band, processes share the CPU via round-robin + * with the configured quantum — fairness among equals. + * - A running process is preempted immediately when a STRICTLY higher-priority + * process becomes ready (arrival or I/O completion). + * - Quantum expiry only rotates within the same priority band; it does not + * allow a lower-priority process to run while a same-priority one is waiting. + * - Compatible with the aging mechanism: as low-priority processes age their + * currentPriority decreases, eventually entering a higher-priority band. + */ +export const priorityRr: SchedulerAlgorithm = { + select(readyQueue) { + if (readyQueue.length === 0) return null; + + // Find the highest priority level (smallest numeric value) present. + let bestPriority = Infinity; + for (const rt of readyQueue) { + if (rt.currentPriority < bestPriority) bestPriority = rt.currentPriority; + } + + // Within that band, return the first process (FIFO insertion order). + // The engine maintains insertion order, so this gives round-robin rotation + // after quantum expiry: the expired process goes to the tail, the next + // same-priority process is now at the head. + return readyQueue.find(rt => rt.currentPriority === bestPriority) ?? null; + }, + + // Preempt ONLY for strictly higher priority. + // Same-priority candidates are handled by quantum expiry — no unnecessary + // context switches within a priority band. + shouldPreempt(current, candidate) { + return candidate.currentPriority < current.currentPriority; + }, + + // Quantum expiry drives round-robin rotation within a priority band, + // exactly like plain Round Robin. + isQuantumExpired(runtime) { + return runtime.quantumRemaining <= 0; + }, +}; diff --git a/src/core/presets.ts b/src/core/presets.ts index 5e52c81..bb3df64 100644 --- a/src/core/presets.ts +++ b/src/core/presets.ts @@ -67,6 +67,16 @@ export const presets: Preset[] = [ ], config: defaultConfig({ algorithm: "PRIORITY_P", isPreemptive: true, agingEnabled: true, agingInterval: 3 }), }, + { + name: "priority_rr_demo", + description: "Priority RR: P3 (priority 1) preempts mid-run; P1 & P2 (priority 2) share CPU via RR", + processes: [ + { id: "p1", name: "P1", arrivalTime: 0, priority: 2, color: "#4FC3F7", bursts: [{ type: "cpu", duration: 4 }] }, + { id: "p2", name: "P2", arrivalTime: 0, priority: 2, color: "#81C784", bursts: [{ type: "cpu", duration: 4 }] }, + { id: "p3", name: "P3", arrivalTime: 4, priority: 1, color: "#F06292", bursts: [{ type: "cpu", duration: 2 }] }, + ], + config: defaultConfig({ algorithm: "PRIORITY_RR", quantum: 2 }), + }, { name: "io_heavy", description: "Mixed CPU-bound and I/O-bound processes", @@ -77,4 +87,27 @@ export const presets: Preset[] = [ ], config: defaultConfig({ algorithm: "SJF_P", isPreemptive: true }), }, + { + name: "convoy_effect", + description: "One long process blocks shorter ones — FCFS convoy vs SJF comparison", + processes: [ + { id: "p1", name: "P1", arrivalTime: 0, priority: 5, color: "#EF5350", bursts: [{ type: "cpu", duration: 12 }] }, + { id: "p2", name: "P2", arrivalTime: 1, priority: 5, color: "#81C784", bursts: [{ type: "cpu", duration: 2 }] }, + { id: "p3", name: "P3", arrivalTime: 2, priority: 5, color: "#FFB74D", bursts: [{ type: "cpu", duration: 1 }] }, + { id: "p4", name: "P4", arrivalTime: 3, priority: 5, color: "#CE93D8", bursts: [{ type: "cpu", duration: 3 }] }, + ], + config: defaultConfig({ algorithm: "FCFS" }), + }, + { + name: "multilevel_demo", + description: "Priority RR with 3 priority tiers — demonstrates multilevel scheduling", + processes: [ + { id: "p1", name: "P1", arrivalTime: 0, priority: 1, color: "#EF5350", bursts: [{ type: "cpu", duration: 4 }] }, + { id: "p2", name: "P2", arrivalTime: 0, priority: 1, color: "#FF8A65", bursts: [{ type: "cpu", duration: 3 }] }, + { id: "p3", name: "P3", arrivalTime: 2, priority: 2, color: "#81C784", bursts: [{ type: "cpu", duration: 5 }] }, + { id: "p4", name: "P4", arrivalTime: 2, priority: 2, color: "#4FC3F7", bursts: [{ type: "cpu", duration: 2 }] }, + { id: "p5", name: "P5", arrivalTime: 4, priority: 3, color: "#CE93D8", bursts: [{ type: "cpu", duration: 6 }] }, + ], + config: defaultConfig({ algorithm: "PRIORITY_RR", quantum: 2 }), + }, ]; diff --git a/src/core/types.ts b/src/core/types.ts index f948320..b42eb21 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -9,6 +9,7 @@ export type SchedulingAlgorithm = | "RR" | "PRIORITY_NP" | "PRIORITY_P" + | "PRIORITY_RR" | "MULTILEVEL" | "HRRN"; diff --git a/src/hooks/useExport.ts b/src/hooks/useExport.ts new file mode 100644 index 0000000..a795788 --- /dev/null +++ b/src/hooks/useExport.ts @@ -0,0 +1,56 @@ +import type { ProcessMetrics } from "@core/types"; +import type { Process } from "@core/types"; + +interface ProcessMap { + [id: string]: Process; +} + +function downloadBlob(content: string, filename: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export function useExport() { + const exportCSV = (metrics: ProcessMetrics[], processMap: ProcessMap) => { + const headers = [ + "Processo", + "Arrival", + "Start", + "Finish", + "Response", + "Turnaround", + "Waiting", + "CPU Time", + "I/O Time", + "CPU%", + "Bound", + ]; + + const rows = metrics.map((m) => { + const name = processMap[m.processId]?.name ?? m.processId; + return [ + name, + m.arrivalTime, + m.startTick, + m.finishTick, + m.responseTime, + m.turnaroundTime, + m.waitingTime, + m.cpuTime, + m.ioTime, + m.cpuUtilization.toFixed(1), + m.boundType, + ].join(","); + }); + + const csv = [headers.join(","), ...rows].join("\n"); + downloadBlob(csv, "pss-metrics.csv", "text/csv;charset=utf-8;"); + }; + + return { exportCSV }; +} diff --git a/src/hooks/useGanttScroll.ts b/src/hooks/useGanttScroll.ts new file mode 100644 index 0000000..ecefb51 --- /dev/null +++ b/src/hooks/useGanttScroll.ts @@ -0,0 +1,22 @@ +import { useEffect, type RefObject } from "react"; +import { CELL_W } from "../components/GanttChart/constants"; + +/** + * Smoothly scrolls `containerRef` so the current tick stays horizontally + * centred in the viewport. Only triggers when the simulation is actively + * running (to avoid stealing the user's manual scroll while paused). + */ +export function useGanttScroll( + containerRef: RefObject, + currentTickIndex: number, + isRunning: boolean, +) { + useEffect(() => { + if (!isRunning) return; + const el = containerRef.current; + if (!el) return; + + const targetLeft = currentTickIndex * CELL_W - el.clientWidth / 2 + CELL_W / 2; + el.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth" }); + }, [containerRef, currentTickIndex, isRunning]); +} diff --git a/src/index.css b/src/index.css index c426e83..68161dd 100644 --- a/src/index.css +++ b/src/index.css @@ -17,4 +17,28 @@ -ms-overflow-style: none; scrollbar-width: none; } + + /* Custom scrollbar for Gantt */ + .gantt-scrollbar::-webkit-scrollbar { + height: 6px; + } + .gantt-scrollbar::-webkit-scrollbar-track { + background: #0f172a; /* slate-900 */ + } + .gantt-scrollbar::-webkit-scrollbar-thumb { + background: #334155; /* slate-700 */ + border-radius: 10px; + } + .gantt-scrollbar::-webkit-scrollbar-thumb:hover { + background: #475569; /* slate-600 */ + } + + @keyframes gantt-cursor-pulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 1; } + } + + .gantt-cursor { + animation: gantt-cursor-pulse 2s ease-in-out infinite; + } } diff --git a/src/store/simulationStore.ts b/src/store/simulationStore.ts index 049336b..670b293 100644 --- a/src/store/simulationStore.ts +++ b/src/store/simulationStore.ts @@ -64,6 +64,8 @@ export const useSimulationStore = create()((set, get) => ({ const { _processes, _config } = get(); if (_processes.length === 0 || _config === null) return; const engine = new SimulationEngine(_processes, _config); - set({ engine, ticks: [], status: "paused" }); + // Return to "idle" so the next Play/Step re-runs init() with the + // current processStore config (picks up algorithm changes, etc.) + set({ engine, ticks: [], status: "idle" }); }, })); diff --git a/tsconfig.app.json b/tsconfig.app.json index ca97cdd..33c5528 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -19,7 +19,8 @@ "paths": { "@core/*": ["./src/core/*"], "@components/*": ["./src/components/*"], - "@store/*": ["./src/store/*"] + "@store/*": ["./src/store/*"], + "@hooks/*": ["./src/hooks/*"] } }, "include": ["src"] diff --git a/vite.config.ts b/vite.config.ts index 93bb192..7f8d7e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ "@core": path.resolve(__dirname, "./src/core"), "@components": path.resolve(__dirname, "./src/components"), "@store": path.resolve(__dirname, "./src/store"), + "@hooks": path.resolve(__dirname, "./src/hooks"), }, }, });