diff --git a/src/assets/help/tools_upkeep_price_calculator.md b/src/assets/help/tools_upkeep_price_calculator.md new file mode 100644 index 00000000..286944dd --- /dev/null +++ b/src/assets/help/tools_upkeep_price_calculator.md @@ -0,0 +1,22 @@ +The Upkeep Price Calculator helps you find the most cost-effective materials and buildings to fulfill population upkeep needs (Safety, Health, Comfort, Culture, and Education) on governed planets. + +## Building Summary + +Shows an overview of each infrastructure building for the selected need, sorted by total cost efficiency. Use this to quickly compare which building is cheapest to operate. + +- **Total $/Need**: Sum of $/Need for all materials the building consumes. +- **Daily Cost**: Total daily operating cost of the building. +- **Need/Day**: Amount of need the building provides per day. + +A warning icon indicates some materials lack market prices, making the totals incomplete. + +## Material Details + +Lists all materials consumed by buildings for the selected need, sorted by cost efficiency. + +- **$/Need**: Cost per unit of need provided. Lower is better. +- **CX Price**: Market price based on your exchange preferences. +- **Relative Price**: Price a material would need to match the best option's efficiency. If CX Price drops below this, it becomes more efficient. +- **Qty/Day**: Material consumed per day by the building. + +Buildings providing multiple needs (like EMC for Safety+Health, or WCE for Health+Comfort) are accounted for in the calculations. diff --git a/src/features/government/components/UpkeepPriceCalculator.vue b/src/features/government/components/UpkeepPriceCalculator.vue new file mode 100644 index 00000000..64cb1249 --- /dev/null +++ b/src/features/government/components/UpkeepPriceCalculator.vue @@ -0,0 +1,177 @@ + + + diff --git a/src/features/government/composables/useUpkeepBuildings.ts b/src/features/government/composables/useUpkeepBuildings.ts new file mode 100644 index 00000000..590225f7 --- /dev/null +++ b/src/features/government/composables/useUpkeepBuildings.ts @@ -0,0 +1,64 @@ +// Types & Interfaces +import { + IUpkeepBuilding, + UpkeepNeedType, +} from "@/features/government/upkeepCalculations.types"; +import { + UPKEEP_BUILDINGS, + UPKEEP_NEED_TYPES, +} from "@/features/government/upkeepCalculations"; + +/** + * Composable for accessing and filtering upkeep building data + * Reusable by: price calculator, infrastructure planner, budget simulator + */ +export function useUpkeepBuildings() { + const buildings: IUpkeepBuilding[] = UPKEEP_BUILDINGS; + const needTypes: UpkeepNeedType[] = UPKEEP_NEED_TYPES; + + /** + * Gets all buildings that provide a specific need type + * @param needType The type of need + * @returns Array of buildings providing that need + */ + function getBuildingsForNeed(needType: UpkeepNeedType): IUpkeepBuilding[] { + return buildings.filter((building) => building.needs[needType] > 0); + } + + /** + * Gets a building by its ticker + * @param ticker The building ticker (e.g., "SST", "HOS") + * @returns The building or undefined if not found + */ + function getBuildingByTicker(ticker: string): IUpkeepBuilding | undefined { + return buildings.find((building) => building.ticker === ticker); + } + + /** + * Counts how many need types a building provides + * Used to split value equally among needs for multi-need buildings + * @param building The building to check + * @returns Number of need types provided (1 or 2) + */ + function getBuildingNeedCount(building: IUpkeepBuilding): number { + return needTypes.filter((needType) => building.needs[needType] > 0).length; + } + + /** + * Gets all need types that a building provides + * @param building The building to check + * @returns Array of need types provided + */ + function getBuildingNeedTypes(building: IUpkeepBuilding): UpkeepNeedType[] { + return needTypes.filter((needType) => building.needs[needType] > 0); + } + + return { + buildings, + needTypes, + getBuildingsForNeed, + getBuildingByTicker, + getBuildingNeedCount, + getBuildingNeedTypes, + }; +} diff --git a/src/features/government/composables/useUpkeepPriceCalculator.ts b/src/features/government/composables/useUpkeepPriceCalculator.ts new file mode 100644 index 00000000..fac17471 --- /dev/null +++ b/src/features/government/composables/useUpkeepPriceCalculator.ts @@ -0,0 +1,132 @@ +import { Ref, ref, ComputedRef, computed } from "vue"; + +// Composables +import { usePrice } from "@/features/cx/usePrice"; +import { useUpkeepBuildings } from "./useUpkeepBuildings"; + +// Calculations +import { calculatePricePerNeed } from "@/features/government/upkeepCalculations"; + +// Types & Interfaces +import { + IUpkeepMaterialCalculation, + UpkeepNeedType, +} from "@/features/government/upkeepCalculations.types"; + +/** + * Composable for calculating upkeep material prices + * Reusable by: price calculator view, cost analysis, building comparison + */ +export async function useUpkeepPriceCalculator( + cxUuid: Ref, + planetNaturalId: Ref = ref(undefined) +) { + const { getPrice } = await usePrice(cxUuid, planetNaturalId); + const { getBuildingsForNeed, getBuildingNeedCount, needTypes } = + useUpkeepBuildings(); + + const isCalculating: Ref = ref(false); + const calculationResults: Ref< + Record + > = ref({ + safety: [], + health: [], + comfort: [], + culture: [], + education: [], + }); + + /** + * Calculates material prices for a specific need type + * @param needType The type of need to calculate + * @returns Array of material calculations sorted by price per need + */ + async function calculateMaterialsForNeed( + needType: UpkeepNeedType + ): Promise { + const buildings = getBuildingsForNeed(needType); + const calculations: IUpkeepMaterialCalculation[] = []; + + for (const building of buildings) { + const needProvided = building.needs[needType]; + // For buildings with multiple needs, multiply by need count + // since the same materials provide value for all needs + const needCount = getBuildingNeedCount(building); + const effectiveNeed = needProvided * needCount; + + for (const material of building.materials) { + const cxPrice = await getPrice(material.ticker, "BUY"); + const pricePerNeed = calculatePricePerNeed( + cxPrice, + material.qtyPerDay, + effectiveNeed + ); + + calculations.push({ + ticker: material.ticker, + buildingTicker: building.ticker, + qtyPerDay: material.qtyPerDay, + needProvided, + pricePerNeed, + cxPrice, + }); + } + } + + // Sort by price per need ascending, but put items with no price (0) at the end + calculations.sort((a, b) => { + if (a.cxPrice <= 0 && b.cxPrice <= 0) return 0; + if (a.cxPrice <= 0) return 1; + if (b.cxPrice <= 0) return -1; + return a.pricePerNeed - b.pricePerNeed; + }); + + return calculations; + } + + /** + * Calculates material prices for all need types + * @returns Record of all need types with their material calculations + */ + async function calculateAllNeeds(): Promise< + Record + > { + isCalculating.value = true; + + const results: Record = { + safety: [], + health: [], + comfort: [], + culture: [], + education: [], + }; + + for (const needType of needTypes) { + results[needType] = await calculateMaterialsForNeed(needType); + } + + calculationResults.value = results; + isCalculating.value = false; + + return results; + } + + /** + * Gets the current results for a specific need type + * @param needType The need type to get results for + * @returns Computed array of material calculations + */ + function getResultsForNeed( + needType: Ref + ): ComputedRef { + return computed(() => calculationResults.value[needType.value]); + } + + return { + isCalculating, + calculationResults, + calculateMaterialsForNeed, + calculateAllNeeds, + getResultsForNeed, + }; +} diff --git a/src/features/government/upkeepCalculations.constants.ts b/src/features/government/upkeepCalculations.constants.ts new file mode 100644 index 00000000..93fc9a40 --- /dev/null +++ b/src/features/government/upkeepCalculations.constants.ts @@ -0,0 +1,185 @@ +// Types & Interfaces +import { + IUpkeepBuilding, + UpkeepNeedType, +} from "@/features/government/upkeepCalculations.types"; + +export const UPKEEP_NEED_TYPES: UpkeepNeedType[] = [ + "safety", + "health", + "comfort", + "culture", + "education", +]; + +export const UPKEEP_BUILDINGS: IUpkeepBuilding[] = [ + { + ticker: "SST", + name: "Safety Station", + materials: [ + { ticker: "DW", qtyPerDay: 10 }, + { ticker: "OFF", qtyPerDay: 10 }, + { ticker: "SUN", qtyPerDay: 2 }, + ], + needs: { safety: 833.3, health: 0, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "SDP", + name: "Security Drone Post", + materials: [ + { ticker: "POW", qtyPerDay: 1 }, + { ticker: "RAD", qtyPerDay: 0.47 }, + { ticker: "CCD", qtyPerDay: 0.07 }, + { ticker: "SUD", qtyPerDay: 0.07 }, + ], + needs: { safety: 1250, health: 0, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "EMC", + name: "Emergency Center", + materials: [ + { ticker: "PK", qtyPerDay: 2 }, + { ticker: "POW", qtyPerDay: 0.4 }, + { ticker: "BND", qtyPerDay: 4 }, + { ticker: "RED", qtyPerDay: 0.07 }, + { ticker: "BSC", qtyPerDay: 0.07 }, + ], + needs: { safety: 200, health: 200, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "INF", + name: "Infirmary", + materials: [ + { ticker: "OFF", qtyPerDay: 10 }, + { ticker: "TUB", qtyPerDay: 6.67 }, + { ticker: "STR", qtyPerDay: 0.67 }, + ], + needs: { safety: 0, health: 833.33, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "HOS", + name: "Hospital", + materials: [ + { ticker: "PK", qtyPerDay: 2 }, + { ticker: "SEQ", qtyPerDay: 0.4 }, + { ticker: "BND", qtyPerDay: 4 }, + { ticker: "SDR", qtyPerDay: 0.07 }, + { ticker: "RED", qtyPerDay: 0.07 }, + { ticker: "BSC", qtyPerDay: 0.13 }, + ], + needs: { safety: 0, health: 833.33, comfort: 0, culture: 0, education: 0 }, + }, + { + ticker: "WCE", + name: "Wellness Center", + materials: [ + { ticker: "KOM", qtyPerDay: 4 }, + { ticker: "OLF", qtyPerDay: 2 }, + { ticker: "DW", qtyPerDay: 6 }, + { ticker: "DEC", qtyPerDay: 0.67 }, + { ticker: "PFE", qtyPerDay: 2.67 }, + { ticker: "SOI", qtyPerDay: 6.67 }, + ], + needs: { safety: 0, health: 166.67, comfort: 166.7, culture: 0, education: 0 }, + }, + { + ticker: "PAR", + name: "Park", + materials: [ + { ticker: "DW", qtyPerDay: 10 }, + { ticker: "FOD", qtyPerDay: 6 }, + { ticker: "PFE", qtyPerDay: 2 }, + { ticker: "SOI", qtyPerDay: 3.33 }, + { ticker: "DEC", qtyPerDay: 0.33 }, + ], + needs: { safety: 0, health: 0, comfort: 500, culture: 0, education: 0 }, + }, + { + ticker: "4DA", + name: "4D Arcades", + materials: [ + { ticker: "POW", qtyPerDay: 2 }, + { ticker: "MHP", qtyPerDay: 2 }, + { ticker: "OLF", qtyPerDay: 4 }, + { ticker: "BID", qtyPerDay: 0.2 }, + { ticker: "HOG", qtyPerDay: 0.2 }, + { ticker: "EDC", qtyPerDay: 0.2 }, + ], + needs: { safety: 0, health: 0, comfort: 833.3, culture: 0, education: 0 }, + }, + { + ticker: "ACA", + name: "Art Cafe", + materials: [ + { ticker: "COF", qtyPerDay: 8 }, + { ticker: "OLF", qtyPerDay: 2 }, + { ticker: "VIT", qtyPerDay: 8 }, + { ticker: "DW", qtyPerDay: 10 }, + { ticker: "GL", qtyPerDay: 6.67 }, + { ticker: "DEC", qtyPerDay: 0.67 }, + ], + needs: { safety: 0, health: 0, comfort: 166.7, culture: 166.7, education: 0 }, + }, + { + ticker: "ART", + name: "Art Gallery", + materials: [ + { ticker: "MHP", qtyPerDay: 1 }, + { ticker: "HOG", qtyPerDay: 1 }, + { ticker: "UTS", qtyPerDay: 0.67 }, + { ticker: "DEC", qtyPerDay: 0.67 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 625, education: 0 }, + }, + { + ticker: "VRT", + name: "VR Theater", + materials: [ + { ticker: "POW", qtyPerDay: 1.4 }, + { ticker: "MHP", qtyPerDay: 2 }, + { ticker: "HOG", qtyPerDay: 1.4 }, + { ticker: "OLF", qtyPerDay: 4 }, + { ticker: "BID", qtyPerDay: 0.33 }, + { ticker: "DEC", qtyPerDay: 0.67 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 833.3, education: 0 }, + }, + { + ticker: "PBH", + name: "Planetary Broadcasting Hub", + materials: [ + { ticker: "OFF", qtyPerDay: 10 }, + { ticker: "MHP", qtyPerDay: 1 }, + { ticker: "SP", qtyPerDay: 1.33 }, + { ticker: "AAR", qtyPerDay: 0.67 }, + { ticker: "EDC", qtyPerDay: 0.27 }, + { ticker: "IDC", qtyPerDay: 0.13 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 166.7, education: 166.7 }, + }, + { + ticker: "LIB", + name: "Library", + materials: [ + { ticker: "MHP", qtyPerDay: 1 }, + { ticker: "HOG", qtyPerDay: 1 }, + { ticker: "CD", qtyPerDay: 0.33 }, + { ticker: "DIS", qtyPerDay: 0.33 }, + { ticker: "BID", qtyPerDay: 0.2 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 0, education: 500 }, + }, + { + ticker: "UNI", + name: "University", + materials: [ + { ticker: "COF", qtyPerDay: 10 }, + { ticker: "REA", qtyPerDay: 10 }, + { ticker: "TUB", qtyPerDay: 10 }, + { ticker: "BID", qtyPerDay: 0.33 }, + { ticker: "HD", qtyPerDay: 0.67 }, + { ticker: "IDC", qtyPerDay: 0.2 }, + ], + needs: { safety: 0, health: 0, comfort: 0, culture: 0, education: 833.3 }, + }, +]; diff --git a/src/features/government/upkeepCalculations.ts b/src/features/government/upkeepCalculations.ts new file mode 100644 index 00000000..262ced19 --- /dev/null +++ b/src/features/government/upkeepCalculations.ts @@ -0,0 +1,102 @@ +// Types & Interfaces +import { + IUpkeepBuilding, + IUpkeepMaterialCalculation, + UpkeepNeedType, +} from "@/features/government/upkeepCalculations.types"; + +// Constants +import { + UPKEEP_NEED_TYPES, + UPKEEP_BUILDINGS, +} from "@/features/government/upkeepCalculations.constants"; + +// Re-export constants for backwards compatibility +export { UPKEEP_NEED_TYPES, UPKEEP_BUILDINGS }; + +/** + * Calculates the price per need unit for a material + * @param materialPrice The CX price of the material + * @param qtyPerDay The quantity consumed per day + * @param needProvided The amount of need provided by the building + * @returns Price per need unit + */ +export function calculatePricePerNeed( + materialPrice: number, + qtyPerDay: number, + needProvided: number +): number { + if (needProvided === 0 || qtyPerDay === 0) return Infinity; + const needPerQty = needProvided / qtyPerDay; + return materialPrice / needPerQty; +} + +/** + * Gets all buildings that provide a specific need type + * @param needType The type of need + * @returns Array of buildings providing that need + */ +export function getBuildingsForNeed(needType: UpkeepNeedType): IUpkeepBuilding[] { + return UPKEEP_BUILDINGS.filter((building) => building.needs[needType] > 0); +} + +/** + * Counts how many need types a building provides + * Used to split value equally among needs for multi-need buildings + * @param building The building to check + * @returns Number of need types provided (1 or 2) + */ +export function getBuildingNeedCount(building: IUpkeepBuilding): number { + return UPKEEP_NEED_TYPES.filter((needType) => building.needs[needType] > 0) + .length; +} + +/** + * Calculates material prices for a specific need type + * @param needType The type of need + * @param getPriceFunc Function to get material prices + * @returns Array of material calculations sorted by price per need + */ +export async function calculateMaterialsForNeed( + needType: UpkeepNeedType, + getPriceFunc: (ticker: string) => Promise +): Promise { + const buildings = getBuildingsForNeed(needType); + const calculations: IUpkeepMaterialCalculation[] = []; + + for (const building of buildings) { + const needProvided = building.needs[needType]; + // For buildings with multiple needs, multiply by need count + // since the same materials provide value for all needs + const needCount = getBuildingNeedCount(building); + const effectiveNeed = needProvided * needCount; + + for (const material of building.materials) { + const cxPrice = await getPriceFunc(material.ticker); + const pricePerNeed = calculatePricePerNeed( + cxPrice, + material.qtyPerDay, + effectiveNeed + ); + + calculations.push({ + ticker: material.ticker, + buildingTicker: building.ticker, + qtyPerDay: material.qtyPerDay, + needProvided, + pricePerNeed, + cxPrice, + }); + } + } + + // Sort by price per need ascending, but put items with no price (0) at the end + calculations.sort((a, b) => { + if (a.cxPrice <= 0 && b.cxPrice <= 0) return 0; + if (a.cxPrice <= 0) return 1; + if (b.cxPrice <= 0) return -1; + return a.pricePerNeed - b.pricePerNeed; + }); + + return calculations; +} diff --git a/src/features/government/upkeepCalculations.types.d.ts b/src/features/government/upkeepCalculations.types.d.ts new file mode 100644 index 00000000..1cd723cf --- /dev/null +++ b/src/features/government/upkeepCalculations.types.d.ts @@ -0,0 +1,35 @@ +export type UpkeepNeedType = + | "safety" + | "health" + | "comfort" + | "culture" + | "education"; + +export interface IUpkeepMaterial { + ticker: string; + qtyPerDay: number; +} + +export interface IUpkeepNeeds { + safety: number; + health: number; + comfort: number; + culture: number; + education: number; +} + +export interface IUpkeepBuilding { + ticker: string; + name: string; + materials: IUpkeepMaterial[]; + needs: IUpkeepNeeds; +} + +export interface IUpkeepMaterialCalculation { + ticker: string; + buildingTicker: string; + qtyPerDay: number; + needProvided: number; + pricePerNeed: number; + cxPrice: number; +} diff --git a/src/layout/components/NavigationBar.vue b/src/layout/components/NavigationBar.vue index 6457cc40..9681c915 100644 --- a/src/layout/components/NavigationBar.vue +++ b/src/layout/components/NavigationBar.vue @@ -43,6 +43,7 @@ TravelExploreSharp, KeyboardDoubleArrowLeftSharp, KeyboardDoubleArrowRightSharp, + AccountBalanceSharp, } from "@vicons/material"; const userStore = useUserStore(); @@ -191,6 +192,12 @@ routerLink: "/production-chain", icon: CompareSharp, }, + { + label: "Upkeep Prices", + display: true, + routerLink: "/upkeep-price-calculator", + icon: AccountBalanceSharp, + }, // { // label: "Base Compare", // display: true, diff --git a/src/router/index.ts b/src/router/index.ts index 22ac43b6..71996652 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -124,6 +124,13 @@ const router = createRouter({ meta: { requiresAuth: true }, component: () => import("@/views/tools/ProductionChainView.vue"), }, + { + name: "upkeep-price-calculator", + path: "/upkeep-price-calculator", + meta: { requiresAuth: true }, + component: () => + import("@/views/tools/UpkeepPriceCalculatorView.vue"), + }, { name: "verify-email", path: "/verify-email", diff --git a/src/tests/features/government/upkeepCalculations.test.ts b/src/tests/features/government/upkeepCalculations.test.ts new file mode 100644 index 00000000..42100c65 --- /dev/null +++ b/src/tests/features/government/upkeepCalculations.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "vitest"; + +import { + calculatePricePerNeed, + getBuildingsForNeed, + getBuildingNeedCount, + calculateMaterialsForNeed, + UPKEEP_BUILDINGS, + UPKEEP_NEED_TYPES, +} from "@/features/government/upkeepCalculations"; + +import type { IUpkeepBuilding } from "@/features/government/upkeepCalculations.types"; + +describe("Government: Upkeep Calculations", () => { + describe("UPKEEP_NEED_TYPES", () => { + it("should contain all 5 need types", () => { + expect(UPKEEP_NEED_TYPES).toHaveLength(5); + expect(UPKEEP_NEED_TYPES).toContain("safety"); + expect(UPKEEP_NEED_TYPES).toContain("health"); + expect(UPKEEP_NEED_TYPES).toContain("comfort"); + expect(UPKEEP_NEED_TYPES).toContain("culture"); + expect(UPKEEP_NEED_TYPES).toContain("education"); + }); + }); + + describe("UPKEEP_BUILDINGS", () => { + it("should contain all 14 upkeep buildings", () => { + expect(UPKEEP_BUILDINGS).toHaveLength(14); + }); + + it("should have valid structure for each building", () => { + for (const building of UPKEEP_BUILDINGS) { + expect(building).toHaveProperty("ticker"); + expect(building).toHaveProperty("name"); + expect(building).toHaveProperty("materials"); + expect(building).toHaveProperty("needs"); + expect(building.materials.length).toBeGreaterThan(0); + } + }); + }); + + describe("calculatePricePerNeed", () => { + it("should calculate price per need correctly", () => { + // Material price: 100, qty per day: 10, need provided: 500 + // Need per qty = 500 / 10 = 50 + // Price per need = 100 / 50 = 2 + const result = calculatePricePerNeed(100, 10, 500); + expect(result).toBe(2); + }); + + it("should return Infinity when needProvided is 0", () => { + const result = calculatePricePerNeed(100, 10, 0); + expect(result).toBe(Infinity); + }); + + it("should return Infinity when qtyPerDay is 0", () => { + const result = calculatePricePerNeed(100, 0, 500); + expect(result).toBe(Infinity); + }); + + it("should handle decimal values correctly", () => { + // Material price: 50, qty per day: 0.5, need provided: 833.3 + // Need per qty = 833.3 / 0.5 = 1666.6 + // Price per need = 50 / 1666.6 ≈ 0.03 + const result = calculatePricePerNeed(50, 0.5, 833.3); + expect(result).toBeCloseTo(0.03, 2); + }); + }); + + describe("getBuildingsForNeed", () => { + it("should return buildings that provide safety", () => { + const buildings = getBuildingsForNeed("safety"); + expect(buildings.length).toBeGreaterThan(0); + + // SST, SDP, and EMC provide safety + const tickers = buildings.map((b) => b.ticker); + expect(tickers).toContain("SST"); + expect(tickers).toContain("SDP"); + expect(tickers).toContain("EMC"); + }); + + it("should return buildings that provide health", () => { + const buildings = getBuildingsForNeed("health"); + expect(buildings.length).toBeGreaterThan(0); + + // INF, HOS, WCE, EMC provide health + const tickers = buildings.map((b) => b.ticker); + expect(tickers).toContain("INF"); + expect(tickers).toContain("HOS"); + expect(tickers).toContain("WCE"); + expect(tickers).toContain("EMC"); + }); + + it("should return buildings that provide comfort", () => { + const buildings = getBuildingsForNeed("comfort"); + expect(buildings.length).toBeGreaterThan(0); + + // PAR, 4DA, WCE, ACA provide comfort + const tickers = buildings.map((b) => b.ticker); + expect(tickers).toContain("PAR"); + expect(tickers).toContain("4DA"); + expect(tickers).toContain("WCE"); + expect(tickers).toContain("ACA"); + }); + + it("should return buildings that provide culture", () => { + const buildings = getBuildingsForNeed("culture"); + expect(buildings.length).toBeGreaterThan(0); + + // ART, VRT, PBH, ACA provide culture + const tickers = buildings.map((b) => b.ticker); + expect(tickers).toContain("ART"); + expect(tickers).toContain("VRT"); + expect(tickers).toContain("PBH"); + expect(tickers).toContain("ACA"); + }); + + it("should return buildings that provide education", () => { + const buildings = getBuildingsForNeed("education"); + expect(buildings.length).toBeGreaterThan(0); + + // LIB, UNI, PBH provide education + const tickers = buildings.map((b) => b.ticker); + expect(tickers).toContain("LIB"); + expect(tickers).toContain("UNI"); + expect(tickers).toContain("PBH"); + }); + + it("should not return buildings that don't provide the need", () => { + const safetyBuildings = getBuildingsForNeed("safety"); + + // PAR only provides comfort, not safety + const tickers = safetyBuildings.map((b) => b.ticker); + expect(tickers).not.toContain("PAR"); + }); + }); + + describe("getBuildingNeedCount", () => { + it("should return 1 for single-need buildings", () => { + // SST only provides safety + const sst = UPKEEP_BUILDINGS.find((b) => b.ticker === "SST"); + expect(sst).toBeDefined(); + expect(getBuildingNeedCount(sst as IUpkeepBuilding)).toBe(1); + + // PAR only provides comfort + const par = UPKEEP_BUILDINGS.find((b) => b.ticker === "PAR"); + expect(par).toBeDefined(); + expect(getBuildingNeedCount(par as IUpkeepBuilding)).toBe(1); + }); + + it("should return 2 for dual-need buildings", () => { + // EMC provides safety and health + const emc = UPKEEP_BUILDINGS.find((b) => b.ticker === "EMC"); + expect(emc).toBeDefined(); + expect(getBuildingNeedCount(emc as IUpkeepBuilding)).toBe(2); + + // WCE provides health and comfort + const wce = UPKEEP_BUILDINGS.find((b) => b.ticker === "WCE"); + expect(wce).toBeDefined(); + expect(getBuildingNeedCount(wce as IUpkeepBuilding)).toBe(2); + + // PBH provides culture and education + const pbh = UPKEEP_BUILDINGS.find((b) => b.ticker === "PBH"); + expect(pbh).toBeDefined(); + expect(getBuildingNeedCount(pbh as IUpkeepBuilding)).toBe(2); + }); + }); + + describe("calculateMaterialsForNeed", () => { + it("should return calculations for all materials in buildings providing the need", async () => { + const mockGetPrice = async (ticker: string) => { + const prices: Record = { + DW: 50, + OFF: 100, + SUN: 500, + }; + return prices[ticker] || 0; + }; + + const calculations = await calculateMaterialsForNeed( + "safety", + mockGetPrice + ); + + expect(calculations.length).toBeGreaterThan(0); + + // Check that SST materials are included + const dwCalc = calculations.find( + (c) => c.ticker === "DW" && c.buildingTicker === "SST" + ); + expect(dwCalc).toBeDefined(); + expect(dwCalc?.cxPrice).toBe(50); + }); + + it("should sort calculations by price per need ascending", async () => { + const mockGetPrice = async (ticker: string) => { + const prices: Record = { + DW: 50, + OFF: 100, + SUN: 500, + POW: 200, + RAD: 1000, + CCD: 5000, + SUD: 5000, + PK: 50, + BND: 20, + RED: 2000, + BSC: 3000, + }; + return prices[ticker] || 0; + }; + + const calculations = await calculateMaterialsForNeed( + "safety", + mockGetPrice + ); + + // Filter only priced calculations + const pricedCalcs = calculations.filter((c) => c.cxPrice > 0); + + // Check that they're sorted by pricePerNeed + for (let i = 1; i < pricedCalcs.length; i++) { + expect(pricedCalcs[i].pricePerNeed).toBeGreaterThanOrEqual( + pricedCalcs[i - 1].pricePerNeed + ); + } + }); + + it("should put materials with no price at the end", async () => { + const mockGetPrice = async (ticker: string) => { + // Only return price for some materials + const prices: Record = { + DW: 50, + OFF: 100, + }; + return prices[ticker] || 0; + }; + + const calculations = await calculateMaterialsForNeed( + "safety", + mockGetPrice + ); + + // Find materials with price 0 + const zeroPriceCalcs = calculations.filter((c) => c.cxPrice === 0); + const pricedCalcs = calculations.filter((c) => c.cxPrice > 0); + + if (zeroPriceCalcs.length > 0 && pricedCalcs.length > 0) { + // Get the index of the first zero-price calc + const firstZeroIndex = calculations.findIndex((c) => c.cxPrice === 0); + // Get the index of the last priced calc + const lastPricedIndex = + calculations.length - + 1 - + [...calculations].reverse().findIndex((c) => c.cxPrice > 0); + + // All zero-price calcs should come after all priced calcs + expect(firstZeroIndex).toBeGreaterThan(lastPricedIndex); + } + }); + + }); +}); diff --git a/src/views/tools/UpkeepPriceCalculatorView.vue b/src/views/tools/UpkeepPriceCalculatorView.vue new file mode 100644 index 00000000..b44953a8 --- /dev/null +++ b/src/views/tools/UpkeepPriceCalculatorView.vue @@ -0,0 +1,58 @@ + + +