diff --git a/angular.json b/angular.json index 7555a61..f1d76ae 100644 --- a/angular.json +++ b/angular.json @@ -143,8 +143,20 @@ ], "scripts": [] } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "src/**/*.ts", + "src/**/*.html" + ] + } } } } + }, + "cli": { + "analytics": false } } diff --git a/docs/AGENT_PROMPT.md b/docs/AGENT_PROMPT.md new file mode 100644 index 0000000..5977e80 --- /dev/null +++ b/docs/AGENT_PROMPT.md @@ -0,0 +1,140 @@ +You are a senior Staff-level Angular + TypeScript engineer and security-minded reviewer. + +Repository context: + +- This is an Angular project recreating a Mac OS desktop experience in a web browser using Tailwind CSS. +- Priorities: maintainability, clarity, performance, and safe-by-default patterns. +- Keep behavior/UI/UX stable unless you are fixing a clear bug. + +Primary goal: +Do a comprehensive review of the codebase and clean it up: organize, optimize, remove tech debt, align with industry standards, and improve safety/security. + +Non-goals / Constraints: + +- Do NOT do major framework upgrades (Angular major version, Tailwind major version) unless absolutely necessary; if you believe it’s necessary, document the rationale and stop before applying it. +- Avoid adding new dependencies unless there’s a strong justification (lint/format/test tooling is allowed if the repo is missing basics, but prefer using existing tooling). +- Keep refactors incremental and reviewable (small cohesive commits if possible). +- Do not delete functionality; deprecate or isolate if needed. +- If you are uncertain whether a change alters runtime behavior, do NOT guess—document the uncertainty and propose a safer alternative. + +Workflow (follow in order): + +1) Baseline discovery (no code changes yet) + +- Read and summarize: package.json scripts, angular.json, tsconfig*, tailwind config, and overall folder layout. +- Identify how the app is structured (apps/windows/overlays/app manager/etc). +- Identify quality gates that exist already (lint, format, tests, build). +- If tool execution is available, run the project’s existing commands (in this order): install (npm ci or npm install), lint, unit tests, build. Record outputs. + +2) Produce an “Audit & Plan” before refactoring + Create a short plan (bulleted) and categorize findings: + +- Quick wins (mechanical / low risk) +- Medium refactors (worth it but need care) +- Larger changes (risky; propose but do not implement unless clearly safe) + For each item: describe the problem, proposed fix, risk level, and how you’ll validate it. + +Pay special attention to these services (review them early and document issues/patterns): + +- sound.service.ts +- user.service.ts +- overlay.service.ts +- cli.service.ts +- typewriter.service.ts +- settings.service.ts +- application-manager.service.ts +- media.service.ts +- storage.service.ts +- file-system.service.ts +- game-config.service.ts + +3) Implement improvements (in small, safe steps) + Apply the plan with an emphasis on: + Code quality & architecture: + +- Consistent naming, folder structure, and separation of concerns. +- Reduce circular dependencies and “god services”. +- Prefer explicit interfaces/types over `any`. +- Improve error handling; remove noisy logs; standardize logging if present. + Angular & RxJS best practices: +- Ensure subscriptions don’t leak (use takeUntilDestroyed if available; otherwise a consistent teardown pattern). +- Avoid nested subscriptions; prefer pipeable operators; handle errors. +- Avoid direct DOM manipulation; use Angular patterns (Renderer2, sanitization where appropriate). + Performance: +- Avoid heavy synchronous work on the UI thread; debounce/throttle where appropriate. +- Ensure Tailwind usage is efficient (remove unused classes/duplicates if reasonable). + Security / safety review: +- Check for XSS vectors (innerHTML, bypassSecurityTrust*, DOM insertion). +- Validate/sanitize any user-controlled inputs (e.g., CLI commands, filenames, “paths”, storage keys). +- Ensure localStorage/sessionStorage usage is safe and scoped; avoid storing secrets. +- Run npm audit if available; do NOT blindly “audit fix” if it risks breaking builds—document what you would change. + +Validation: + +- After each meaningful change, rerun the relevant checks (lint/tests/build). +- If tests are missing for critical logic, add a minimal set for the riskiest modules/services (prioritize CLI parsing, file-system/storage behaviors, and application/window manager state logic). + +4) Create documentation under /docs (required output) + Create /docs at the project root (if it doesn’t exist), and organize it into these subfolders: + +- /docs/ARCHITECTURE +- /docs/README +- /docs/TODOS +- /docs/FUTURE_FEATURES + (If the repository already uses the misspellings “ARCHETECTURE” or “FURTURE_FFEATURES”, match the existing convention, but prefer corrected names for new repos.) + +All docs must be Markdown. Create at minimum: + +/docs/README/INDEX.md + +- A hub that links to every doc below. + +/docs/README/PROJECT_OVERVIEW.md + +- What this project is, the user experience it recreates, key features. +- Tech stack summary (Angular + Tailwind + notable libs). +- Folder/module map and where to start reading the code. + +/docs/README/DEVELOPMENT.md + +- Setup instructions, common scripts, how to run locally, how to build, how to test, troubleshooting tips. + +/docs/ARCHITECTURE/OVERVIEW.md + +- System overview and major subsystems (desktop, windows/apps, overlays, settings, storage, media/sound, CLI). +- Include a Mermaid diagram for high-level architecture (components/services and relationships). + +/docs/ARCHITECTURE/SERVICES.md + +- A section for each key service listed above: + - Responsibility + - Key methods/events/observables + - Dependencies (what it calls / what calls it) + - Risks/footguns + - Recommended improvements (and what you implemented) + +/docs/ARCHITECTURE/STATE_EVENTS.md + +- Explain state management approach, event flows, window/app lifecycle, persistence strategy, and how overlays interact with apps/windows. + +/docs/ARCHITECTURE/SECURITY.md + +- Threat model relevant to this app (XSS, injection via CLI, unsafe storage, supply chain). +- Findings + mitigations you applied; remaining risks. + +/docs/TODOS/TECH_DEBT.md + +- A prioritized checklist of remaining tech debt with impact + effort estimates (S/M/L) and suggested order of operations. + +/docs/FUTURE_FEATURES/ROADMAP.md + +- A realistic roadmap: short-term enhancements, medium-term refactors, long-term features. +- Call out dependencies/risks for each. + +5) Final output (what you tell me) + When finished, respond with: + +- A concise summary of what you changed and why (grouped by category). +- Commands you ran + results (lint/tests/build/audit). +- A “review checklist” for me to validate changes quickly. +- Any follow-up items you intentionally did not change (and why). diff --git a/eslint.config.mjs b/eslint.config.mjs index 19c2576..3218c2d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,30 +1,31 @@ -import js from "@eslint/js"; +import eslint from "@eslint/js"; import globals from "globals"; import tseslint from "typescript-eslint"; -import json from "@eslint/json"; -import { defineConfig } from "eslint/config"; -import angular from "@analogjs/vite-plugin-angular"; -import eslint from "@eslint/css"; +import angular from "angular-eslint"; - -export default defineConfig([ - { files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] }, - { files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.browser } }, - tseslint.configs.recommended, - { files: ["**/*.json"], plugins: { json }, language: "json/json", extends: ["json/recommended"] }, - { files: ["**/*.jsonc"], plugins: { json }, language: "json/jsonc", extends: ["json/recommended"] }, - {files: ["**/*.css"], plugins: {eslint}, language: "css/css", extends: ["css/recommended"]}, -]); - -module.exports = tseslint.config( +export default tseslint.config( + { + ignores: [ + "dist/**", + "coverage/**", + "node_modules/**", + ".angular/**", + "out-tsc/**" + ] + }, { files: ["**/*.ts"], extends: [ eslint.configs.recommended, ...tseslint.configs.recommended, - ...tseslint.configs.stylistic, - ...angular.configs.tsRecommended, + ...angular.configs.tsRecommended ], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + }, processor: angular.processInlineTemplates, rules: { "@angular-eslint/directive-selector": [ @@ -32,25 +33,24 @@ module.exports = tseslint.config( { type: "attribute", prefix: "app", - style: "camelCase", - }, + style: "camelCase" + } ], "@angular-eslint/component-selector": [ "error", { type: "element", prefix: "app", - style: "kebab-case", - }, - ], - }, + style: "kebab-case" + } + ] + } }, { files: ["**/*.html"], extends: [ ...angular.configs.templateRecommended, - ...angular.configs.templateAccessibility, - ], - rules: {}, + ...angular.configs.templateAccessibility + ] } ); diff --git a/package.json b/package.json index d240a6a..e029a66 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,12 @@ "build": "ng build", "build:css": "npx tailwindcss -o ./dist/output.css --minify", "watch": "ng build --watch --configuration development", - "test": "ng test --include='**/'", + "test": "ng test", "lint": "ng lint" }, + "engines": { + "node": ">=20.11 <23" + }, "private": true, "dependencies": { "@angular/animations": "^19.2.14", diff --git a/src/app/components/game/apps/cli-game/cli-game.component.spec.ts b/src/app/components/game/apps/cli-game/cli-game.component.spec.ts index 4acced1..aea9f34 100644 --- a/src/app/components/game/apps/cli-game/cli-game.component.spec.ts +++ b/src/app/components/game/apps/cli-game/cli-game.component.spec.ts @@ -1,14 +1,68 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {BehaviorSubject, of} from 'rxjs'; +import {TypewriterService} from '../../services/typewriter.service'; +import {SoundService} from '../../services/sound.service'; +import {CLIService} from '../../services/cli.service'; +import {GameConfigService} from '../../services/game-config.service'; +import {ApplicationManagerService} from '../../services/application-manager.service'; +import {AiChatService} from '../../services/ai-chat.service'; +import {UserService} from '../../services/user.service'; +import {NotificationService} from '../../services/notification.service'; +import {LogService} from '../../services/log.service'; import { CliGameComponent } from './cli-game.component'; describe('CliGameComponent', () => { let component: CliGameComponent; let fixture: ComponentFixture; + const typewriterServiceMock = { + typedText$: new BehaviorSubject(''), + activeMode$: new BehaviorSubject<'default' | 'system' | 'dramatic'>('default'), + lineCompleted$: new BehaviorSubject(undefined), + enqueueLine: jasmine.createSpy('enqueueLine'), + clear: jasmine.createSpy('clear') + }; + const soundServiceMock = jasmine.createSpyObj('SoundService', ['bootAudio', 'stopAll', 'play']); + soundServiceMock.bootAudio.and.returnValue(Promise.resolve()); + const cliServiceMock = { + executeInput: jasmine.createSpy('executeInput').and.returnValue({status: 200, output: 'ok'}) + }; + const gameConfigServiceMock = { + getAvailableCommands: jasmine.createSpy('getAvailableCommands').and.returnValue([]), + loadLevels: jasmine.createSpy('loadLevels').and.returnValue(Promise.resolve()) + }; + const appManagerServiceMock = { + closeApplication: jasmine.createSpy('closeApplication') + }; + const aiChatServiceMock = { + generateAiAnswer: jasmine.createSpy('generateAiAnswer').and.returnValue(of({choices: []})) + }; + const userServiceMock = { + user: {level: 1}, + previousLevel: 1 + }; + const notificationServiceMock = { + show: jasmine.createSpy('show') + }; + const loggerMock = { + logs$: new BehaviorSubject([]), + getLogsPage: jasmine.createSpy('getLogsPage').and.returnValue([]) + }; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [CliGameComponent] + imports: [CliGameComponent], + providers: [ + {provide: TypewriterService, useValue: typewriterServiceMock}, + {provide: SoundService, useValue: soundServiceMock}, + {provide: CLIService, useValue: cliServiceMock}, + {provide: GameConfigService, useValue: gameConfigServiceMock}, + {provide: ApplicationManagerService, useValue: appManagerServiceMock}, + {provide: AiChatService, useValue: aiChatServiceMock}, + {provide: UserService, useValue: userServiceMock}, + {provide: NotificationService, useValue: notificationServiceMock}, + {provide: LogService, useValue: loggerMock} + ] }) .compileComponents(); diff --git a/src/app/components/game/apps/space-x/space-x.component.spec.ts b/src/app/components/game/apps/space-x/space-x.component.spec.ts index 99ae23c..131abe1 100644 --- a/src/app/components/game/apps/space-x/space-x.component.spec.ts +++ b/src/app/components/game/apps/space-x/space-x.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {SpaceXComponent} from './space-x.component'; @@ -8,7 +10,8 @@ describe('SpaceXComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpaceXComponent] + imports: [SpaceXComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/apps/space-x/spacex-crew/spacex-crew.component.spec.ts b/src/app/components/game/apps/space-x/spacex-crew/spacex-crew.component.spec.ts index afed228..dd5ca9a 100644 --- a/src/app/components/game/apps/space-x/spacex-crew/spacex-crew.component.spec.ts +++ b/src/app/components/game/apps/space-x/spacex-crew/spacex-crew.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {SpacexCrewComponent} from './spacex-crew.component'; @@ -8,7 +10,8 @@ describe('SpacexCrewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpacexCrewComponent] + imports: [SpacexCrewComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/apps/space-x/spacex-launchpad/spacex-launchpad.component.spec.ts b/src/app/components/game/apps/space-x/spacex-launchpad/spacex-launchpad.component.spec.ts index 0dd3963..c29ccb3 100644 --- a/src/app/components/game/apps/space-x/spacex-launchpad/spacex-launchpad.component.spec.ts +++ b/src/app/components/game/apps/space-x/spacex-launchpad/spacex-launchpad.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {SpacexLaunchpadComponent} from './spacex-launchpad.component'; @@ -8,7 +10,8 @@ describe('SpacexLaunchpadComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpacexLaunchpadComponent] + imports: [SpacexLaunchpadComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/apps/space-x/spacex-rocket/spacex-rocket.component.spec.ts b/src/app/components/game/apps/space-x/spacex-rocket/spacex-rocket.component.spec.ts index bdf21e2..684cb36 100644 --- a/src/app/components/game/apps/space-x/spacex-rocket/spacex-rocket.component.spec.ts +++ b/src/app/components/game/apps/space-x/spacex-rocket/spacex-rocket.component.spec.ts @@ -1,4 +1,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; +import {SpaceXRocket} from '../models/spacex-models'; import {SpacexRocketComponent} from './spacex-rocket.component'; @@ -8,12 +11,40 @@ describe('SpacexRocketComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpacexRocketComponent] + imports: [SpacexRocketComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); fixture = TestBed.createComponent(SpacexRocketComponent); component = fixture.componentInstance; + component.rocket = { + name: 'Falcon 9', + active: true, + flickr_images: [], + company: 'SpaceX', + country: 'USA', + first_flight: '2010-06-04', + cost_per_launch: 62000000, + success_rate_pct: 98, + description: 'Test', + height: {meters: 70, feet: 229.6}, + diameter: {meters: 3.7, feet: 12}, + mass: {kg: 549054, lb: 1207920}, + engines: { + number: 9, + type: 'merlin', + version: '1D+', + thrust_vacuum: {kN: 8227, lbf: 1849500}, + propellant_1: 'RP-1', + propellant_2: 'LOX' + }, + stages: 2, + boosters: 0, + payload_weights: [], + id: 'falcon9', + wikipedia: '' + } as unknown as SpaceXRocket; fixture.detectChanges(); }); diff --git a/src/app/components/game/apps/space-x/spacex-sub-panel/spacex-sub-panel.component.spec.ts b/src/app/components/game/apps/space-x/spacex-sub-panel/spacex-sub-panel.component.spec.ts index 1a71c79..9a9634e 100644 --- a/src/app/components/game/apps/space-x/spacex-sub-panel/spacex-sub-panel.component.spec.ts +++ b/src/app/components/game/apps/space-x/spacex-sub-panel/spacex-sub-panel.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {SpacexSubPanelComponent} from './spacex-sub-panel.component'; @@ -8,7 +10,8 @@ describe('SpacexSubPanelComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpacexSubPanelComponent] + imports: [SpacexSubPanelComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/apps/weather/weather.component.spec.ts b/src/app/components/game/apps/weather/weather.component.spec.ts index 99d9513..0a81019 100644 --- a/src/app/components/game/apps/weather/weather.component.spec.ts +++ b/src/app/components/game/apps/weather/weather.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {WeatherComponent} from './weather.component'; @@ -8,7 +10,8 @@ describe('WeatherComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [WeatherComponent] + imports: [WeatherComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/desktop/desktop.component.spec.ts b/src/app/components/game/desktop/desktop.component.spec.ts index f547519..b03eb8f 100644 --- a/src/app/components/game/desktop/desktop.component.spec.ts +++ b/src/app/components/game/desktop/desktop.component.spec.ts @@ -9,6 +9,7 @@ import { TypewriterService } from '../services/typewriter.service'; import { SoundService } from '../services/sound.service'; import { ActivatedRoute } from '@angular/router'; import { of } from 'rxjs'; +import {LogService} from '../services/log.service'; describe('DesktopComponent', () => { let component: DesktopComponent; @@ -56,9 +57,23 @@ describe('DesktopComponent', () => { }), }; + const logServiceMock = { + debug: jasmine.createSpy('debug'), + info: jasmine.createSpy('info'), + warn: jasmine.createSpy('warn'), + error: jasmine.createSpy('error'), + }; + beforeEach(async () => { + TestBed.overrideComponent(DesktopComponent, { + set: { + template: '', + imports: [], + } + }); + await TestBed.configureTestingModule({ - declarations: [DesktopComponent], + imports: [DesktopComponent], providers: [ { provide: ApplicationManagerService, useValue: appManagerServiceMock }, { provide: ContextMenuService, useValue: contextMenuServiceMock }, @@ -68,6 +83,7 @@ describe('DesktopComponent', () => { { provide: TypewriterService, useValue: typewriterServiceMock }, { provide: SoundService, useValue: soundServiceMock }, { provide: ActivatedRoute, useValue: activatedRouteMock }, + {provide: LogService, useValue: logServiceMock}, ], }).compileComponents(); @@ -80,18 +96,17 @@ describe('DesktopComponent', () => { expect(component).toBeTruthy(); }); - it('should call onBeginInvestigation if no user exists in localStorage', () => { + it('should call onBeginInvestigation when view initializes', () => { spyOn(component, 'onBeginInvestigation'); - spyOn(localStorage, 'getItem').and.returnValue(null); - - component.ngOnInit(); + component.ngAfterViewInit(); expect(component.onBeginInvestigation).toHaveBeenCalled(); }); it('should call openApp on route param change', () => { + appManagerServiceMock.openApplication.calls.reset(); component.ngOnInit(); - expect(appManagerServiceMock.openApplication).toHaveBeenCalledWith('testApp'); + expect(appManagerServiceMock.openApplication).toHaveBeenCalledWith('testApp', undefined); }); it('should handle onDoubleClicked and close all apps', () => { @@ -142,7 +157,7 @@ describe('DesktopComponent', () => { component.onBeginInvestigation(); expect(soundServiceMock.play).toHaveBeenCalledWith('glitch-1.mp3', { - volume: 0.1, + volume: 0.3, forceRestart: true, }); expect(typewriterServiceMock.enqueueLine).toHaveBeenCalledWith({ diff --git a/src/app/components/game/services/application-manager.service.ts b/src/app/components/game/services/application-manager.service.ts index 98471c1..c6758ea 100644 --- a/src/app/components/game/services/application-manager.service.ts +++ b/src/app/components/game/services/application-manager.service.ts @@ -107,6 +107,7 @@ export const DEFAULT_WINDOW_OFFSET_X = 40; const INSTANCE_LIMIT_ERROR_MESSAGE = "Cannot open application. Maximum number of instances reached."; const INSTANCE_LIMIT_ERROR_TITLE = "System Error"; +const OPEN_APPS_STORAGE_KEY = 'applications'; export enum APP_ID { @@ -497,9 +498,39 @@ export class ApplicationManagerService { } private loadSavedApplications() { - const savedApps = localStorage.getItem('applications'); - if (savedApps) { - JSON.parse(savedApps).map((a: { id: any; }) => a.id).map((id: string) => this.openApplication(id)); + const appIds = this.getSavedApplicationIds(); + for (const appId of appIds) { + this.openApplication(appId); + } + } + + private getSavedApplicationIds(): string[] { + const savedApps = localStorage.getItem(OPEN_APPS_STORAGE_KEY); + if (!savedApps) { + return []; + } + + try { + const parsed: unknown = JSON.parse(savedApps); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((entry) => { + if (typeof entry === 'string') { + return entry; + } + if (entry && typeof entry === 'object' && 'id' in entry) { + const maybeId = (entry as { id?: unknown }).id; + return typeof maybeId === 'string' ? maybeId : null; + } + return null; + }) + .filter((id): id is string => Boolean(id)); + } catch (error) { + this.logger.warn('Failed to parse saved applications.', {error}); + return []; } } @@ -548,9 +579,16 @@ export class ApplicationManagerService { const focusId = this.focusedAppId.getValue(); - if (focusId === id || app?.running) { - return !this.setApplicationFocus(id); + if (focusId === id) return true; + + if (app?.running) { + const existing = this.getMostRecentApplicationInstance(id); + if (existing) { + this.setApplicationFocus(existing.id, existing.offsetX, existing.offsetY); + return true; + } } + if (!app) return false; if (this.usedMemory + app.memory > this.maxMemory) { @@ -572,14 +610,9 @@ export class ApplicationManagerService { return false; } - const isNewInstanceNeeded = !!this.applications.value.find(t => t.id === id); - - const curAppIndex = this.applications.value.length + 1; - - const newAppInstanceId = isNewInstanceNeeded ? `${id}-${curAppIndex}` : id; - - - app.instanceIndex = isNewInstanceNeeded ? app.instanceIndex + 1 : app.instanceIndex; + const openInstanceCount = this.getOpenInstanceCount(app.id); + const newAppInstanceId = openInstanceCount > 0 ? `${id}-${openInstanceCount + 1}` : id; + app.instanceIndex = openInstanceCount + 1; app.running = true; this.applications.next([...this.applications.value, this.appFactory @@ -604,8 +637,8 @@ export class ApplicationManagerService { } private isInstanceLimitReached(app: AppEntry): boolean { - if (app.instanceIndex < app.maxInstances) { - app.instanceIndex += 1; // Increment instanceIndex when under limit + const openInstanceCount = this.getOpenInstanceCount(app.id); + if (openInstanceCount < app.maxInstances) { return false; } @@ -629,29 +662,39 @@ export class ApplicationManagerService { saveOpenApplications() { - localStorage.setItem('applications', JSON.stringify(this.applications.value)); + const openAppIds = this.applications.value.map((app) => app.id); + localStorage.setItem(OPEN_APPS_STORAGE_KEY, JSON.stringify(openAppIds)); } - closeApplication(id: string, args?: any): void { + closeApplication(id: string): void { const application = this.getAppByID(id); if (!application) return; // Mark the application as no longer running application.running = false; // Decrement the instanceIndex for the parent AppEntry + // Remove the application from the active applications list + const remainingApplications = this.applications.getValue().filter(app => app.id !== id); + this.applications.next(remainingApplications); + if (application.parent) { - application.parent.instanceIndex = Math.max(0, application.parent.instanceIndex - 1); - application.parent.running = application.parent.instanceIndex > 0; + const remainingInstances = remainingApplications.filter((openApp) => openApp.parent?.id === application.parent?.id); + application.parent.instanceIndex = remainingInstances.length; + application.parent.running = remainingInstances.length > 0; } - application.instanceIndex = 0; - - // Remove the application from the active applications list - this.applications.next(this.applications.getValue().filter(app => app.id !== id)); - // Save the state of opened applications this.saveOpenApplications(); } + private getOpenInstanceCount(appId: string): number { + return this.applications.value.filter((openApp) => openApp.parent?.id === appId).length; + } + + private getMostRecentApplicationInstance(appId: string): ApplicationInstance | undefined { + const appInstances = this.applications.value.filter((openApp) => openApp.parent?.id === appId); + return appInstances[appInstances.length - 1]; + } + setApplicationFocus(id: string, offsetX?: number, offsetY?: number): boolean { const application = this.applications.value.find(t => t.id === id); if (id === 'desktop') { diff --git a/src/app/components/game/services/cli.service.ts b/src/app/components/game/services/cli.service.ts index c0ebd1c..4459bf4 100644 --- a/src/app/components/game/services/cli.service.ts +++ b/src/app/components/game/services/cli.service.ts @@ -24,12 +24,9 @@ export class CLIService { private commands = new Map(); constructor(private config: GameConfigService, private userService: UserService) { - this.config.loadLevelsForProgress().then((levels) => { - levels.subscribe((level) => { - console.warn('level', level); - }); - this.registerBuiltins(); - }); + this.config.loadLevelsForProgress() + .then(() => this.registerBuiltins()) + .catch(() => this.registerBuiltins()); } private registerBuiltins() { @@ -37,7 +34,6 @@ export class CLIService { name: 'help', description: 'List available commands', execute: () => { - console.warn('commands', this.commands.keys()); const commands = '\n ' + Array.from(this.commands.keys()).join('\n '); return { status: commands ? 200 : 404, @@ -48,11 +44,13 @@ export class CLIService { this.registerCommand({ name: 'whoami', description: 'Returns user identity', - execute: () => ({ - status: localStorage.getItem('user') ? 200 : 404, - output: localStorage.getItem('user') || 'Unknown' - } - ) + execute: () => { + const username = this.userService.user.name?.trim(); + return { + status: username ? 200 : 404, + output: username || 'Unknown' + }; + } }); this.registerCommand({ name: 'exit', @@ -80,7 +78,6 @@ export class CLIService { name: 'leet', description: 'Convert text to leet speak', execute: (args: string[]) => { - console.warn('params', args); if (!args.length) { return { status: 400, @@ -140,7 +137,7 @@ export class CLIService { const isAuthorized = (password: string) => { return password === '1234'; } // implement secure validation - if (!isAuthorized) { + if (!isAuthorized(password)) { return { status: 401, output: 'Unauthorized' @@ -152,13 +149,13 @@ export class CLIService { output: `Already logged in as admin.` }; } - this.userService.updateUser({name: username, level: 2, score: this.userService.user.score + 1}); + void this.userService.updateUser({name: username, level: 2, score: this.userService.user.score + 1}); return { status: 201, output: `Switched to user: ${username}` }; } - this.userService.updateUser({name: username, level: 1, score: 0}); + void this.userService.updateUser({name: username, level: 1, score: 0}); return { status: 201, output: `Switched to user: ${username}` @@ -182,8 +179,8 @@ export class CLIService { output: 'Unauthorized!' }; } else { - this.userService.updateUser({[param]: value}); - this.userService.updateUser({name: 'unknown'}); + void this.userService.updateUser({[param]: value}); + void this.userService.updateUser({name: 'unknown'}); return { status: 200, output: `Updated user: ${param} to ${value}` diff --git a/src/app/components/game/services/file-system.service.ts b/src/app/components/game/services/file-system.service.ts index 32a0f2a..ca944a4 100644 --- a/src/app/components/game/services/file-system.service.ts +++ b/src/app/components/game/services/file-system.service.ts @@ -108,8 +108,6 @@ export class FileSystemService { {name: 'Documents', path: '/Documents', icon: this.getIconForType('folder')}, {name: 'Photos', path: '/Photos', icon: this.getIconForType('folder')}, {name: 'Videos', path: '/Videos', icon: this.getIconForType('folder')}, - {name: 'Downloads', path: '/Downloads', icon: this.getIconForType('folder')}, - {name: 'Music', path: '/Music', icon: this.getIconForType('folder')}, {name: 'Recents', path: '/Recents', icon: this.getIconForType('folder')} ]; @@ -278,7 +276,7 @@ export class FileSystemService { } return { - name: faker.person.jobType(), + name, path: `${path}`.replace(/\/+/g, '/'), // Normalize the path created: new Date().toISOString(), modified: new Date().toISOString(), @@ -301,7 +299,7 @@ export class FileSystemService { // Normalize the parent folder path and create the folder const folderPath = `${path.replace(/\/+$/, '')}/${name}`; - const folder: FileEntry = this.createFolder(name, path, false); + const folder: FileEntry = this.createFolder(name, folderPath, false); // Create a set to track existing children paths and ensure no duplicates const childPaths = new Set(); diff --git a/src/app/components/game/services/log.service.ts b/src/app/components/game/services/log.service.ts index b16f5b3..d447539 100644 --- a/src/app/components/game/services/log.service.ts +++ b/src/app/components/game/services/log.service.ts @@ -1,7 +1,6 @@ import {inject, Injectable} from '@angular/core'; import {BehaviorSubject, Observable} from 'rxjs'; import {FirestoreService} from '../../../services/firebase/firestore.service'; -import {user} from '@angular/fire/auth'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -17,7 +16,13 @@ export class LogService { private logSubject = new BehaviorSubject([]); private mutedLevels: Set = new Set(); private globalMute = false; - private firestore: FirestoreService = inject(FirestoreService); + private firestore: FirestoreService | null = (() => { + try { + return inject(FirestoreService); + } catch { + return null; + } + })(); get logs(): LogEntry[] { return [...this.logBuffer]; @@ -91,21 +96,23 @@ export class LogService { const entry: LogEntry = {level, message, timestamp: new Date()}; - this.firestore.saveLogEntry( - { - level: entry.level, - message: typeof entry.message === 'string' ? entry.message : '', - userId: user.name, - metadata: 'log entry' - }, - ).subscribe({ - next: () => { - // Successfully saved log to Firestore - }, - error: (err) => { - console.error('Failed to save log to Firestore:', err); - } - }); + if (this.firestore) { + this.firestore.saveLogEntry( + { + level: entry.level, + message: typeof entry.message === 'string' ? entry.message : '', + userId: 'unknown', + metadata: 'log entry' + }, + ).subscribe({ + next: () => { + // Successfully saved log to Firestore + }, + error: (err) => { + console.error('Failed to save log to Firestore:', err); + } + }); + } this.logBuffer.push(entry); this.logSubject.next([...this.logBuffer]); diff --git a/src/app/components/game/services/settings.service.ts b/src/app/components/game/services/settings.service.ts index 4fa007a..bfb2e83 100644 --- a/src/app/components/game/services/settings.service.ts +++ b/src/app/components/game/services/settings.service.ts @@ -3,6 +3,7 @@ import {BehaviorSubject} from 'rxjs'; import {StorageService} from './storage.service'; import {NotificationService} from './notification.service'; import {FormControl, FormGroup} from '@angular/forms'; +import {Subscription} from 'rxjs'; export interface Setting { @@ -48,6 +49,7 @@ export interface SettingsSet { export class SettingsService { private settings = new Map>(); private settingSets = new Map>(); + private settingValueSubjects = new Map>(); constructor(private storageService: StorageService, private notify: NotificationService) { this.loadPersistedSettings(); @@ -225,25 +227,34 @@ export class SettingsService { } getSettingValue$(setId: string, settingId?: string): BehaviorSubject { + const cacheKey = settingId ? `${setId}:${settingId}` : setId; + const existingSubject = this.settingValueSubjects.get(cacheKey) as BehaviorSubject | undefined; + if (existingSubject) { + return existingSubject; + } + if (!settingId) { // Return observable for a single standalone setting const subject = this.getSetting(setId); - return subject ? (subject as BehaviorSubject) : new BehaviorSubject(null); + const fallback = subject ? (subject as BehaviorSubject) : new BehaviorSubject(null); + this.settingValueSubjects.set(cacheKey, fallback as BehaviorSubject); + return fallback; } // Create an on-the-fly observable to watch settingSet changes const settingSet$ = this.getSettingSet(setId); + const subject = new BehaviorSubject(null); if (settingSet$) { - const subject = new BehaviorSubject(null); + const initialValue = this.findSettingValueInSet(setId, settingId); + subject.next(initialValue); settingSet$.subscribe((set) => { const found = set.find((setting) => setting.id === settingId); subject.next(found ? (found.value as T) : null); }); - return subject; } - - return new BehaviorSubject(null); + this.settingValueSubjects.set(cacheKey, subject as BehaviorSubject); + return subject; } createFormGroupForSettings(setId: string): FormGroup | null { @@ -261,8 +272,8 @@ export class SettingsService { ); } - syncFormGroupWithSettingSet(formGroup: FormGroup, setId: string): void { - formGroup.valueChanges.subscribe((newValues) => { + syncFormGroupWithSettingSet(formGroup: FormGroup, setId: string): Subscription { + return formGroup.valueChanges.subscribe((newValues) => { const settingSet = this.getSettingSet(setId); console.warn('settingSet', settingSet?.value); if (!settingSet) { diff --git a/src/app/components/game/services/storage.service.ts b/src/app/components/game/services/storage.service.ts index 4be632e..54b9b53 100644 --- a/src/app/components/game/services/storage.service.ts +++ b/src/app/components/game/services/storage.service.ts @@ -7,6 +7,8 @@ interface StorageStrategy { getItem(key: string): Promise; + getAllKeys(): Promise; + removeItem(key: string): Promise; clear(): Promise; @@ -95,8 +97,12 @@ export class StorageService { } getAllKeys(): Observable { - const keys = Object.keys(localStorage); - return of(keys); + return from(this.strategy.getAllKeys()).pipe( + catchError(error => { + console.error('Storage operation failed:', error); + return of([]); + }) + ); } } @@ -163,6 +169,18 @@ class IndexedDBStrategy implements StorageStrategy { }); } + async getAllKeys(): Promise { + const db = await this.db; + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, 'readonly'); + const store = transaction.objectStore(this.storeName); + const request = store.getAllKeys(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result.map((key) => String(key))); + }); + } + async clear(): Promise { const db = await this.db; return new Promise((resolve, reject) => { @@ -203,6 +221,15 @@ class LocalStorageStrategy implements StorageStrategy { } } + async getAllKeys(): Promise { + try { + return Object.keys(localStorage); + } catch (error) { + console.error('LocalStorage operation failed:', error); + return []; + } + } + async clear(): Promise { try { localStorage.clear(); diff --git a/src/app/components/game/services/typewriter.service.ts b/src/app/components/game/services/typewriter.service.ts index 678e951..f255004 100644 --- a/src/app/components/game/services/typewriter.service.ts +++ b/src/app/components/game/services/typewriter.service.ts @@ -17,13 +17,18 @@ interface TypewriterLine { onComplete?: () => void; } +interface CompletedLineEvent { + text: string; + agent: 'user' | 'system'; +} + @Injectable({ providedIn: 'root' }) export class TypewriterService { public typedText$ = new BehaviorSubject(''); - public lineCompleted$ = new Subject(); + public lineCompleted$ = new Subject(); private queue: TypewriterLine[] = []; private currentIndex = 0; - private typingInterval: any; + private typingInterval: ReturnType | null = null; private lineBuffer = ''; public activeMode$ = new BehaviorSubject('default'); @@ -72,6 +77,8 @@ export class TypewriterService { ' ' ) + line.onBegin?.(); + const config = this.getTypingConfig(mode); this.typingInterval = setInterval(() => this.typeNextChar(line), config.speed); } @@ -79,7 +86,6 @@ export class TypewriterService { private typeNextChar(line: TypewriterLine) { const mode = line.mode ?? 'default'; const config = this.getTypingConfig(mode); - line.onBegin?.(); if (this.currentIndex < line.text.length) { const char = line.text[this.currentIndex]; @@ -98,7 +104,9 @@ export class TypewriterService { line.onCharTyped?.(char, this.currentIndex, mode); } else { - clearInterval(this.typingInterval); + if (this.typingInterval !== null) { + clearInterval(this.typingInterval); + } this.typingInterval = null; line.onComplete?.(); @@ -118,7 +126,9 @@ export class TypewriterService { } clear() { - clearInterval(this.typingInterval); + if (this.typingInterval !== null) { + clearInterval(this.typingInterval); + } this.typingInterval = null; this.queue = []; this.currentIndex = 0; diff --git a/src/app/components/game/system/dock/dock.component.spec.ts b/src/app/components/game/system/dock/dock.component.spec.ts index 8665c70..b8d1225 100644 --- a/src/app/components/game/system/dock/dock.component.spec.ts +++ b/src/app/components/game/system/dock/dock.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import { DockComponent } from './dock.component'; @@ -8,7 +10,8 @@ describe('DockComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DockComponent] + imports: [DockComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/system/dock/dock.component.ts b/src/app/components/game/system/dock/dock.component.ts index fc6e95c..36f89e5 100644 --- a/src/app/components/game/system/dock/dock.component.ts +++ b/src/app/components/game/system/dock/dock.component.ts @@ -120,8 +120,8 @@ export class DockComponent { this.appManager.openApplication(id, args); } - closeApp(id: string, args?: any) { - this.appManager.closeApplication(id, args); + closeApp(id: string) { + this.appManager.closeApplication(id); } trash(key: string) { diff --git a/src/app/components/game/system/finder-app/finder-app.component.spec.ts b/src/app/components/game/system/finder-app/finder-app.component.spec.ts index 8503f42..67433e2 100644 --- a/src/app/components/game/system/finder-app/finder-app.component.spec.ts +++ b/src/app/components/game/system/finder-app/finder-app.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import { FinderAppComponent } from './finder-app.component'; @@ -8,7 +10,8 @@ describe('FinderAppComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [FinderAppComponent] + imports: [FinderAppComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/system/login-screen/login-screen.component.spec.ts b/src/app/components/game/system/login-screen/login-screen.component.spec.ts index 8b1ddec..9ac8278 100644 --- a/src/app/components/game/system/login-screen/login-screen.component.spec.ts +++ b/src/app/components/game/system/login-screen/login-screen.component.spec.ts @@ -1,14 +1,43 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {ActivatedRoute} from '@angular/router'; +import {of} from 'rxjs'; +import {RouterTestingModule} from '@angular/router/testing'; +import {AuthService} from '../../../../services/auth.service'; +import {UserService} from '../../services/user.service'; +import {SoundService} from '../../services/sound.service'; +import {MusicService} from '../../services/music.service'; +import {LogService} from '../../services/log.service'; import { LoginScreenComponent } from './login-screen.component'; describe('LoginScreenComponent', () => { let component: LoginScreenComponent; let fixture: ComponentFixture; + const authServiceMock = { + user$: of(null), + handleRedirectResult: jasmine.createSpy('handleRedirectResult').and.returnValue(of(null)), + signInWithEmail: jasmine.createSpy('signInWithEmail').and.returnValue(of(null)), + registerWithEmail: jasmine.createSpy('registerWithEmail').and.returnValue(of(null)), + loginWithGoogle: jasmine.createSpy('loginWithGoogle').and.returnValue(of(null)) + }; + const userServiceMock = { + updateUser: jasmine.createSpy('updateUser').and.returnValue(Promise.resolve()) + }; + const soundServiceMock = jasmine.createSpyObj('SoundService', ['stopAll']); + const musicServiceMock = jasmine.createSpyObj('MusicService', ['stopAll']); + const loggerMock = jasmine.createSpyObj('LogService', ['info', 'error', 'warn', 'debug']); beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LoginScreenComponent] + imports: [LoginScreenComponent, RouterTestingModule], + providers: [ + {provide: AuthService, useValue: authServiceMock}, + {provide: UserService, useValue: userServiceMock}, + {provide: SoundService, useValue: soundServiceMock}, + {provide: MusicService, useValue: musicServiceMock}, + {provide: LogService, useValue: loggerMock}, + {provide: ActivatedRoute, useValue: {queryParams: of({})}} + ] }) .compileComponents(); diff --git a/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts b/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts index bde473e..4a717b2 100644 --- a/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts +++ b/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts @@ -1,7 +1,8 @@ -import { Component, OnInit, inject } from '@angular/core'; +import {Component, OnDestroy, OnInit, inject} from '@angular/core'; import { CommonModule } from '@angular/common'; import {Setting, SettingsService} from '../../../../services/settings.service'; import {FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {Subscription} from 'rxjs'; export type ThemeOption = 'light' | 'dark' | 'system'; @Component({ @@ -44,9 +45,10 @@ export type ThemeOption = 'light' | 'dark' | 'system'; `, styles: `` }) -export class AppearanceSettingsComponent implements OnInit { +export class AppearanceSettingsComponent implements OnInit, OnDestroy { private settingsService = inject(SettingsService); private readonly settingsSetId = 'appearance'; + private formSyncSub?: Subscription; formGroup!: FormGroup; accentColor: string = '#4f46e5'; theme: ThemeOption = 'light'; @@ -75,7 +77,7 @@ export class AppearanceSettingsComponent implements OnInit { this.formGroup = formGroup; this.settingKeys = Object.keys(this.formGroup.controls); console.warn('Form group created:', this.formGroup.value, this.settingKeys, this.settingsSetId); - this.settingsService.syncFormGroupWithSettingSet(this.formGroup, this.settingsSetId); + this.formSyncSub = this.settingsService.syncFormGroupWithSettingSet(this.formGroup, this.settingsSetId); } } @@ -88,4 +90,8 @@ export class AppearanceSettingsComponent implements OnInit { console.log('Settings saved:', this.formGroup.value); } + ngOnDestroy(): void { + this.formSyncSub?.unsubscribe(); + } + } diff --git a/src/app/components/game/system/system-tray/system-tray.component.spec.ts b/src/app/components/game/system/system-tray/system-tray.component.spec.ts index cf4583e..665e884 100644 --- a/src/app/components/game/system/system-tray/system-tray.component.spec.ts +++ b/src/app/components/game/system/system-tray/system-tray.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import { SystemTrayComponent } from './system-tray.component'; @@ -8,7 +10,8 @@ describe('SystemTrayComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SystemTrayComponent] + imports: [SystemTrayComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/templates/app-window/app-window.component.spec.ts b/src/app/components/game/templates/app-window/app-window.component.spec.ts index 8773183..e376abe 100644 --- a/src/app/components/game/templates/app-window/app-window.component.spec.ts +++ b/src/app/components/game/templates/app-window/app-window.component.spec.ts @@ -14,7 +14,6 @@ describe('TerminalWindowComponent', () => { fixture = TestBed.createComponent(AppWindowComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/components/game/templates/context-menu/context-menu.component.spec.ts b/src/app/components/game/templates/context-menu/context-menu.component.spec.ts index c61e2a1..149ef87 100644 --- a/src/app/components/game/templates/context-menu/context-menu.component.spec.ts +++ b/src/app/components/game/templates/context-menu/context-menu.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {CONTEXT_MENU_DATA} from '../../services/context-menu.service'; import { ContextMenuComponent } from './context-menu.component'; @@ -8,7 +9,11 @@ describe('ContextMenuComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ContextMenuComponent] + imports: [ContextMenuComponent], + providers: [{ + provide: CONTEXT_MENU_DATA, + useValue: {menuId: 'test-menu', items: []} + }] }) .compileComponents(); diff --git a/src/app/components/game/templates/intro-overlay/intro-overlay.component.spec.ts b/src/app/components/game/templates/intro-overlay/intro-overlay.component.spec.ts index 376ad73..cd3c658 100644 --- a/src/app/components/game/templates/intro-overlay/intro-overlay.component.spec.ts +++ b/src/app/components/game/templates/intro-overlay/intro-overlay.component.spec.ts @@ -1,14 +1,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {SoundService} from '../../services/sound.service'; +import {RouterTestingModule} from '@angular/router/testing'; import { IntroOverlayComponent } from './intro-overlay.component'; describe('IntroOverlayComponent', () => { let component: IntroOverlayComponent; let fixture: ComponentFixture; + const soundServiceMock = jasmine.createSpyObj('SoundService', ['bootAudio', 'playVariant']); + soundServiceMock.bootAudio.and.returnValue(Promise.resolve()); beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [IntroOverlayComponent] + imports: [IntroOverlayComponent, RouterTestingModule], + providers: [{provide: SoundService, useValue: soundServiceMock}] }) .compileComponents(); diff --git a/src/app/components/game/templates/tooltip-overlay/tooltip-overlay.component.spec.ts b/src/app/components/game/templates/tooltip-overlay/tooltip-overlay.component.spec.ts index 39a360d..66100f0 100644 --- a/src/app/components/game/templates/tooltip-overlay/tooltip-overlay.component.spec.ts +++ b/src/app/components/game/templates/tooltip-overlay/tooltip-overlay.component.spec.ts @@ -14,6 +14,18 @@ describe('TooltipOverlayComponent', () => { fixture = TestBed.createComponent(TooltipOverlayComponent); component = fixture.componentInstance; + component.hostElement = document.createElement('div'); + spyOn(component.hostElement, 'getBoundingClientRect').and.returnValue({ + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}) + }); fixture.detectChanges(); }); diff --git a/src/app/components/game/utils/level-loader/level-loader.component.spec.ts b/src/app/components/game/utils/level-loader/level-loader.component.spec.ts index 356dc19..dbd3d62 100644 --- a/src/app/components/game/utils/level-loader/level-loader.component.spec.ts +++ b/src/app/components/game/utils/level-loader/level-loader.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import { LevelLoaderComponent } from './level-loader.component'; @@ -8,7 +10,8 @@ describe('LevelLoaderComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LevelLoaderComponent] + imports: [LevelLoaderComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/utils/notifications-server/notifications-server.component.html b/src/app/components/game/utils/notifications-server/notifications-server.component.html index 4a22924..ce50d76 100644 --- a/src/app/components/game/utils/notifications-server/notifications-server.component.html +++ b/src/app/components/game/utils/notifications-server/notifications-server.component.html @@ -1,7 +1,7 @@
setTimeout(res, 30)); } diff --git a/src/app/components/main/home-terminal-window/home-terminal-window.component.spec.ts b/src/app/components/main/home-terminal-window/home-terminal-window.component.spec.ts index 49db0cf..5177f55 100644 --- a/src/app/components/main/home-terminal-window/home-terminal-window.component.spec.ts +++ b/src/app/components/main/home-terminal-window/home-terminal-window.component.spec.ts @@ -1,14 +1,27 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {HomeTerminalWindowComponent} from './home-terminal-window.component'; +import {TypewriterService} from '../../game/services/typewriter.service'; +import {BehaviorSubject} from 'rxjs'; describe('HomeTerminalWindowComponent', () => { let component: HomeTerminalWindowComponent; let fixture: ComponentFixture; + const typedText$ = new BehaviorSubject(''); + const typewriterServiceMock = { + enableSound: jasmine.createSpy('enableSound'), + setVolume: jasmine.createSpy('setVolume'), + typedText$, + clear: jasmine.createSpy('clear'), + enqueueLine: jasmine.createSpy('enqueueLine'), + }; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [HomeTerminalWindowComponent] + imports: [HomeTerminalWindowComponent], + providers: [ + {provide: TypewriterService, useValue: typewriterServiceMock} + ] }) .compileComponents(); diff --git a/src/app/components/main/joke-tray/joke-tray.component.spec.ts b/src/app/components/main/joke-tray/joke-tray.component.spec.ts index 4c684dd..06fd46c 100644 --- a/src/app/components/main/joke-tray/joke-tray.component.spec.ts +++ b/src/app/components/main/joke-tray/joke-tray.component.spec.ts @@ -1,4 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; +import {RouterTestingModule} from '@angular/router/testing'; +import {defaultSoundConfig, SOUND_SERVICE_CONFIG} from '../../../providers/sound/sound.module'; import { JokeTrayComponent } from './joke-tray.component'; @@ -8,7 +12,12 @@ describe('JokeTrayComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [JokeTrayComponent] + imports: [JokeTrayComponent, RouterTestingModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + {provide: SOUND_SERVICE_CONFIG, useValue: defaultSoundConfig} + ] }) .compileComponents(); diff --git a/src/app/components/main/joke-tray/joke-tray.component.ts b/src/app/components/main/joke-tray/joke-tray.component.ts index e3a3d4b..4703c8f 100644 --- a/src/app/components/main/joke-tray/joke-tray.component.ts +++ b/src/app/components/main/joke-tray/joke-tray.component.ts @@ -6,7 +6,7 @@ import {INotification, NotificationService} from '../../game/services/notificati import {JokesService} from '../../game/services/jokes.service'; import {SoundService} from '../../game/services/sound.service'; import {RouterLink} from '@angular/router'; -import {HOME_NOTIFY_CLASSES} from '../main.component'; +import {HOME_NOTIFY_CLASSES} from '../main.constants'; import {faSmile} from '@fortawesome/free-regular-svg-icons'; import {FaIconComponent} from '@fortawesome/angular-fontawesome'; import {TooltipDirective} from '../../game/directives/tooltip.directive'; diff --git a/src/app/components/main/main.component.ts b/src/app/components/main/main.component.ts index 17a9cf0..9c50af2 100644 --- a/src/app/components/main/main.component.ts +++ b/src/app/components/main/main.component.ts @@ -18,8 +18,7 @@ import {HomeTerminalWindowComponent} from './home-terminal-window/home-terminal- import {ProjectsOverviewComponent} from './projects-overview/projects-overview.component'; import {ProjectItemComponent} from './project-item/project-item.component'; import {DisclaimerComponent} from './disclaimer/disclaimer.component'; - -export const HOME_NOTIFY_CLASSES = 'bg-black/80 text-green-500 border-2 border-green-500'; +import {HOME_NOTIFY_CLASSES} from './main.constants'; @Component({ selector: 'app-main', diff --git a/src/app/components/main/projects-overview/projects-overview.component.spec.ts b/src/app/components/main/projects-overview/projects-overview.component.spec.ts index 3e1fb03..8e87a0d 100644 --- a/src/app/components/main/projects-overview/projects-overview.component.spec.ts +++ b/src/app/components/main/projects-overview/projects-overview.component.spec.ts @@ -1,4 +1,5 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {RouterTestingModule} from '@angular/router/testing'; import {ProjectsOverviewComponent} from './projects-overview.component'; @@ -8,7 +9,7 @@ describe('ProjectsOverviewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ProjectsOverviewComponent] + imports: [ProjectsOverviewComponent, RouterTestingModule] }) .compileComponents(); diff --git a/src/app/components/main/socials/socials.component.spec.ts b/src/app/components/main/socials/socials.component.spec.ts index 473c5cf..e51636c 100644 --- a/src/app/components/main/socials/socials.component.spec.ts +++ b/src/app/components/main/socials/socials.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {RouterTestingModule} from '@angular/router/testing'; import { SocialsComponent } from './socials.component'; @@ -8,7 +9,7 @@ describe('SocialsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SocialsComponent] + imports: [SocialsComponent, RouterTestingModule] }) .compileComponents(); diff --git a/src/app/guards/redirect.guard.ts b/src/app/guards/redirect.guard.ts index 50de3ae..5330931 100644 --- a/src/app/guards/redirect.guard.ts +++ b/src/app/guards/redirect.guard.ts @@ -1,8 +1,27 @@ import { CanActivateFn } from '@angular/router'; -export const redirectGuard: CanActivateFn = (route, state) => { - if(!route.params['externalUrl']) return true; - const url = decodeURIComponent(route.params['externalUrl']); - window.open(url, '_blank'); +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); + +function parseSafeExternalUrl(raw: string): string | null { + try { + const decoded = decodeURIComponent(raw).trim(); + const parsed = new URL(decoded); + if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + +export const redirectGuard: CanActivateFn = (route) => { + const externalUrl = route.params['externalUrl']; + if (!externalUrl) return true; + + const safeUrl = parseSafeExternalUrl(externalUrl); + if (!safeUrl) return false; + + window.open(safeUrl, '_blank', 'noopener,noreferrer'); return false; }; diff --git a/src/app/pipes/obfuscate.pipe.spec.ts b/src/app/pipes/obfuscate.pipe.spec.ts index 6537d4c..d615a77 100644 --- a/src/app/pipes/obfuscate.pipe.spec.ts +++ b/src/app/pipes/obfuscate.pipe.spec.ts @@ -19,6 +19,6 @@ describe('ObfuscatePipe', () => { it('should handle unsupported characters', () => { const cipher = { 'h': 'x', 'e': 'y' }; - expect(pipe.transform('hi there!', cipher, 'encode')).toEqual('xi tyery!'); + expect(pipe.transform('hi there!', cipher, 'encode')).toEqual('xi txyry!'); }); }); diff --git a/src/app/services/auth.service.spec.ts b/src/app/services/auth.service.spec.ts index ee124a6..beab00c 100644 --- a/src/app/services/auth.service.spec.ts +++ b/src/app/services/auth.service.spec.ts @@ -1,12 +1,24 @@ import {TestBed} from '@angular/core/testing'; import {AuthService} from './auth.service'; +import {Auth} from '@angular/fire/auth'; +import {Router} from '@angular/router'; +import {LogService} from '../components/game/services/log.service'; describe('AuthService', () => { let service: AuthService; + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + const logServiceSpy = jasmine.createSpyObj('LogService', ['debug', 'info', 'warn', 'error']); + beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [ + {provide: Auth, useValue: {} as Auth}, + {provide: Router, useValue: routerSpy}, + {provide: LogService, useValue: logServiceSpy}, + ] + }); service = TestBed.inject(AuthService); }); diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index b2ccd9c..0f0e519 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -22,18 +22,23 @@ import {LogService} from '../components/game/services/log.service'; providedIn: 'root' }) export class AuthService { - private auth: Auth = inject(Auth); + private auth: Auth | null = inject(Auth, {optional: true}); - user$: Observable = user(this.auth); + user$: Observable = this.auth ? user(this.auth) : of(null); constructor( private router: Router, private readonly logger: LogService, private zone: NgZone ) { + const auth = this.auth; + if (!auth) { + this.logger.warn('Auth service initialized without Firebase Auth provider.'); + return; + } this.user$ = new Observable(observer => { - return onAuthStateChanged(this.auth, + return onAuthStateChanged(auth, user => this.zone.run(() => observer.next(user)), error => this.zone.run(() => observer.error(error)), () => this.zone.run(() => observer.complete()) @@ -44,6 +49,9 @@ export class AuthService { // Email & Password Sign In signInWithEmail(email: string, password: string): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } return from(signInWithEmailAndPassword(this.auth, email, password)).pipe( tap(result => this.logger.info('Signed in!', result.user)), catchError(error => { @@ -55,6 +63,9 @@ export class AuthService { // Email & Password Registration registerWithEmail(email: string, password: string): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } return from(createUserWithEmailAndPassword(this.auth, email, password)).pipe( switchMap(credentials => { // Send email verification @@ -71,6 +82,9 @@ export class AuthService { // Google Sign In (enhanced with Observable) loginWithGoogle(): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } const provider = new GoogleAuthProvider(); // Use signInWithPopup instead of redirect for more reliable behavior return from(signInWithPopup(this.auth, provider)); @@ -78,7 +92,11 @@ export class AuthService { // Add a method to handle redirect results handleRedirectResult(): Observable { - return from(this.zone.runOutsideAngular(() => getRedirectResult(this.auth))) + const auth = this.auth; + if (!auth) { + return of(null); + } + return from(this.zone.runOutsideAngular(() => getRedirectResult(auth))) .pipe( tap(result => { if (result) { @@ -95,6 +113,9 @@ export class AuthService { // Password Reset resetPassword(email: string): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } return from(sendPasswordResetEmail(this.auth, email)).pipe( catchError(error => { this.logger.error('Password reset failed:', error); @@ -105,6 +126,9 @@ export class AuthService { // Sign Out (enhanced with Observable) logout(): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } return from(signOut(this.auth)).pipe( tap(() => { this.logger.info('Signed out'); diff --git a/src/app/services/firebase/firestore.service.spec.ts b/src/app/services/firebase/firestore.service.spec.ts index 0cd3dcf..10c5f13 100644 --- a/src/app/services/firebase/firestore.service.spec.ts +++ b/src/app/services/firebase/firestore.service.spec.ts @@ -34,6 +34,8 @@ describe('FirestoreService', () => { let mockUploadBytesResumable: jasmine.Spy; beforeEach(() => { + jasmine.getEnv().allowRespy(true); + const firestoreSpy = jasmine.createSpyObj('Firestore', ['app']); const storageSpy = jasmine.createSpyObj('Storage', ['app']); diff --git a/src/app/services/firebase/firestore.service.ts b/src/app/services/firebase/firestore.service.ts index 6c79ec2..df538d3 100644 --- a/src/app/services/firebase/firestore.service.ts +++ b/src/app/services/firebase/firestore.service.ts @@ -1,29 +1,30 @@ import {Injectable} from '@angular/core'; import { - collection, - deleteDoc, - doc, + collection as collectionFn, + deleteDoc as deleteDocFn, + doc as docFn, Firestore, - getDoc, - getDocs, - limit, - onSnapshot, - orderBy, - query, - serverTimestamp, - setDoc, + getDoc as getDocFn, + getDocs as getDocsFn, + limit as limitFn, + onSnapshot as onSnapshotFn, + orderBy as orderByFn, + query as queryFn, + serverTimestamp as serverTimestampFn, + setDoc as setDocFn, Timestamp, - updateDoc, - where, writeBatch + updateDoc as updateDocFn, + where as whereFn, + writeBatch as writeBatchFn } from '@angular/fire/firestore'; import { - deleteObject, - getDownloadURL, - ref, + deleteObject as deleteObjectFn, + getDownloadURL as getDownloadURLFn, + ref as storageRefFn, Storage, - uploadBytes, - uploadBytesResumable, - uploadString + uploadBytes as uploadBytesFn, + uploadBytesResumable as uploadBytesResumableFn, + uploadString as uploadStringFn } from '@angular/fire/storage'; import {from, Observable, throwError, of} from 'rxjs'; import {catchError, map, switchMap} from 'rxjs/operators'; @@ -35,7 +36,7 @@ export interface FirestoreDocument { createdAt?: Timestamp; updatedAt?: Timestamp; - [key: string]: any; + [key: string]: unknown; } @Injectable({ @@ -48,6 +49,113 @@ export class FirestoreService { ) { } + // Wrappers keep Firebase calls mockable in tests without changing runtime behavior. + private doc(...args: unknown[]): unknown { + return (docFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private collection(...args: unknown[]): unknown { + return (collectionFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private setDoc(...args: unknown[]): Promise { + return (setDocFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private getDoc(...args: unknown[]): Promise { + return (getDocFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private updateDoc(...args: unknown[]): Promise { + return (updateDocFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private deleteDoc(...args: unknown[]): Promise { + return (deleteDocFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private getDocs(...args: unknown[]): Promise { + return (getDocsFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private query(...args: unknown[]): unknown { + return (queryFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private where(...args: unknown[]): unknown { + return (whereFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private orderBy(...args: unknown[]): unknown { + return (orderByFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private limit(...args: unknown[]): unknown { + return (limitFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private onSnapshot(...args: unknown[]): () => void { + return (onSnapshotFn as (...innerArgs: unknown[]) => () => void)(...args); + } + + private serverTimestamp(...args: unknown[]): unknown { + return (serverTimestampFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private writeBatch(...args: unknown[]): { + set: (...batchArgs: unknown[]) => void; + update: (...batchArgs: unknown[]) => void; + delete: (...batchArgs: unknown[]) => void; + commit: () => Promise; + } { + return (writeBatchFn as (...innerArgs: unknown[]) => { + set: (...batchArgs: unknown[]) => void; + update: (...batchArgs: unknown[]) => void; + delete: (...batchArgs: unknown[]) => void; + commit: () => Promise; + })(...args); + } + + private ref(...args: unknown[]): unknown { + return (storageRefFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private uploadBytes(...args: unknown[]): Promise { + return (uploadBytesFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private uploadBytesResumable(...args: unknown[]): { + on: ( + event: string, + progress: (snapshot: { bytesTransferred: number; totalBytes: number }) => void, + error: (error: unknown) => void, + complete: () => void + ) => void; + snapshot: { ref: unknown }; + } { + return (uploadBytesResumableFn as (...innerArgs: unknown[]) => { + on: ( + event: string, + progress: (snapshot: { bytesTransferred: number; totalBytes: number }) => void, + error: (error: unknown) => void, + complete: () => void + ) => void; + snapshot: { ref: unknown }; + })(...args); + } + + private uploadString(...args: unknown[]): Promise { + return (uploadStringFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private getDownloadURL(...args: unknown[]): Promise { + return (getDownloadURLFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private deleteObject(...args: unknown[]): Promise { + return (deleteObjectFn as (...innerArgs: unknown[]) => Promise)(...args); + } + /** * Creates or updates a document in Firestore * @param collectionPath - Path to the collection @@ -61,17 +169,17 @@ export class FirestoreService { id?: string ): Observable { const docId = id || data.id || uuidv4(); - const docRef = doc(this.firestore, collectionPath, docId); + const docRef = this.doc(this.firestore, collectionPath, docId); // Add timestamps const documentData = { ...data, - updatedAt: serverTimestamp(), - createdAt: data.createdAt || serverTimestamp(), + updatedAt: this.serverTimestamp(), + createdAt: data.createdAt || this.serverTimestamp(), id: docId }; - return from(setDoc(docRef, documentData, {merge: true})).pipe( + return from(this.setDoc(docRef, documentData, {merge: true})).pipe( map(() => docId), catchError(error => { console.error(`Error saving document to ${collectionPath}:`, error); @@ -87,12 +195,18 @@ export class FirestoreService { * @returns Observable of the document data */ getDocument(collectionPath: string, id: string): Observable { - const docRef = doc(this.firestore, collectionPath, id); - - return from(getDoc(docRef)).pipe( - map(snapshot => { - if (snapshot.exists()) { - return {id: snapshot.id, ...snapshot.data()} as T; + const docRef = this.doc(this.firestore, collectionPath, id); + + return from(this.getDoc(docRef)).pipe( + map((snapshot: unknown) => { + const typedSnapshot = snapshot as { + exists: () => boolean; + id: string; + data: () => Record; + }; + + if (typedSnapshot.exists()) { + return {id: typedSnapshot.id, ...typedSnapshot.data()} as T; } else { return null; } @@ -116,15 +230,15 @@ export class FirestoreService { id: string, data: T ): Observable { - const docRef = doc(this.firestore, collectionPath, id); + const docRef = this.doc(this.firestore, collectionPath, id); // Add updated timestamp const updateData = { ...data, - updatedAt: serverTimestamp() + updatedAt: this.serverTimestamp() }; - return from(updateDoc(docRef, updateData)).pipe( + return from(this.updateDoc(docRef, updateData)).pipe( catchError(error => { console.error(`Error updating document in ${collectionPath}:`, error); return throwError(() => new Error(`Failed to update document: ${error.message}`)); @@ -139,9 +253,9 @@ export class FirestoreService { * @returns Observable of void */ deleteDocument(collectionPath: string, id: string): Observable { - const docRef = doc(this.firestore, collectionPath, id); + const docRef = this.doc(this.firestore, collectionPath, id); - return from(deleteDoc(docRef)).pipe( + return from(this.deleteDoc(docRef)).pipe( catchError(error => { console.error(`Error deleting document from ${collectionPath}:`, error); return throwError(() => new Error(`Failed to delete document: ${error.message}`)); @@ -165,30 +279,34 @@ export class FirestoreService { sortDirection: 'asc' | 'desc' = 'desc', limitCount?: number ): Observable { - const collectionRef = collection(this.firestore, collectionPath); + const collectionRef = this.collection(this.firestore, collectionPath); - let q = query(collectionRef); + let q = this.query(collectionRef); // Apply filters if provided if (filters && filters.length > 0) { filters.forEach(filter => { - q = query(q, where(filter[0], filter[1], filter[2])); + q = this.query(q, this.where(filter[0], filter[1], filter[2])); }); } // Apply sorting if provided if (sortField) { - q = query(q, orderBy(sortField, sortDirection)); + q = this.query(q, this.orderBy(sortField, sortDirection)); } // Apply limit if provided if (limitCount) { - q = query(q, limit(limitCount)); + q = this.query(q, this.limit(limitCount)); } - return from(getDocs(q)).pipe( - map(snapshot => { - return snapshot.docs.map(doc => ({id: doc.id, ...doc.data()} as T)); + return from(this.getDocs(q)).pipe( + map((snapshot: unknown) => { + const typedSnapshot = snapshot as { + docs: Array<{ id: string; data: () => Record }>; + }; + + return typedSnapshot.docs.map((doc) => ({id: doc.id, ...doc.data()} as T)); }), catchError(error => { console.error(`Error querying documents from ${collectionPath}:`, error); @@ -204,19 +322,25 @@ export class FirestoreService { * @returns Observable that emits the document data on changes */ listenToDocument(collectionPath: string, id: string): Observable { - const docRef = doc(this.firestore, collectionPath, id); + const docRef = this.doc(this.firestore, collectionPath, id); return new Observable(observer => { // Return the unsubscribe function to clean up when the observable is unsubscribed - return onSnapshot(docRef, - (snapshot) => { - if (snapshot.exists()) { - observer.next({id: snapshot.id, ...snapshot.data()} as T); + return this.onSnapshot(docRef, + (snapshot: unknown) => { + const typedSnapshot = snapshot as { + exists: () => boolean; + id: string; + data: () => Record; + }; + + if (typedSnapshot.exists()) { + observer.next({id: typedSnapshot.id, ...typedSnapshot.data()} as T); } else { observer.next(null); } }, - (error) => { + (error: unknown) => { console.error(`Error listening to document in ${collectionPath}:`, error); observer.error(error); } @@ -238,30 +362,33 @@ export class FirestoreService { sortField?: string, sortDirection: 'asc' | 'desc' = 'desc' ): Observable { - const collectionRef = collection(this.firestore, collectionPath); + const collectionRef = this.collection(this.firestore, collectionPath); - let q = query(collectionRef); + let q = this.query(collectionRef); // Apply filters if provided if (filters && filters.length > 0) { filters.forEach(filter => { - q = query(q, where(filter[0], filter[1], filter[2])); + q = this.query(q, this.where(filter[0], filter[1], filter[2])); }); } // Apply sorting if provided if (sortField) { - q = query(q, orderBy(sortField, sortDirection)); + q = this.query(q, this.orderBy(sortField, sortDirection)); } return new Observable(observer => { // Return the unsubscribe function to clean up when the observable is unsubscribed - return onSnapshot(q, - (snapshot) => { - const documents = snapshot.docs.map(doc => ({id: doc.id, ...doc.data()} as T)); + return this.onSnapshot(q, + (snapshot: unknown) => { + const typedSnapshot = snapshot as { + docs: Array<{ id: string; data: () => Record }>; + }; + const documents = typedSnapshot.docs.map((doc) => ({id: doc.id, ...doc.data()} as T)); observer.next(documents); }, - (error) => { + (error: unknown) => { console.error(`Error listening to collection ${collectionPath}:`, error); observer.error(error); } @@ -277,10 +404,10 @@ export class FirestoreService { * @returns Observable of the download URL */ uploadFile(path: string, file: File | Blob, metadata?: any): Observable { - const storageRef = ref(this.storage, path); + const storageRef = this.ref(this.storage, path); - return from(uploadBytes(storageRef, file, metadata)).pipe( - switchMap(() => from(getDownloadURL(storageRef))), + return from(this.uploadBytes(storageRef, file, metadata)).pipe( + switchMap(() => from(this.getDownloadURL(storageRef))), catchError(error => { console.error(`Error uploading file to ${path}:`, error); return throwError(() => new Error(`Failed to upload file: ${error.message}`)); @@ -299,23 +426,23 @@ export class FirestoreService { progress: number, downloadUrl?: string }> { - const storageRef = ref(this.storage, path); - const uploadTask = uploadBytesResumable(storageRef, file, metadata); + const storageRef = this.ref(this.storage, path); + const uploadTask = this.uploadBytesResumable(storageRef, file, metadata); return new Observable<{ progress: number, downloadUrl?: string }>(observer => { uploadTask.on( 'state_changed', - (snapshot) => { + (snapshot: { bytesTransferred: number; totalBytes: number }) => { const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; observer.next({progress}); }, - (error) => { + (error: unknown) => { console.error(`Error uploading file to ${path}:`, error); observer.error(error); }, async () => { try { - const downloadUrl = await getDownloadURL(uploadTask.snapshot.ref); + const downloadUrl = await this.getDownloadURL(uploadTask.snapshot.ref); observer.next({progress: 100, downloadUrl}); observer.complete(); } catch (error) { @@ -334,10 +461,10 @@ export class FirestoreService { * @returns Observable of the download URL */ uploadBase64(path: string, dataUrl: string, metadata?: any): Observable { - const storageRef = ref(this.storage, path); + const storageRef = this.ref(this.storage, path); - return from(uploadString(storageRef, dataUrl, 'data_url', metadata)).pipe( - switchMap(() => from(getDownloadURL(storageRef))), + return from(this.uploadString(storageRef, dataUrl, 'data_url', metadata)).pipe( + switchMap(() => from(this.getDownloadURL(storageRef))), catchError(error => { console.error(`Error uploading base64 to ${path}:`, error); return throwError(() => new Error(`Failed to upload base64: ${error.message}`)); @@ -351,9 +478,9 @@ export class FirestoreService { * @returns Observable of void */ deleteFile(path: string): Observable { - const storageRef = ref(this.storage, path); + const storageRef = this.ref(this.storage, path); - return from(deleteObject(storageRef)).pipe( + return from(this.deleteObject(storageRef)).pipe( catchError(error => { console.error(`Error deleting file from ${path}:`, error); return throwError(() => new Error(`Failed to delete file: ${error.message}`)); @@ -397,7 +524,7 @@ export class FirestoreService { }): Observable { return this.saveDocument('logs', { ...logEntry, - timestamp: serverTimestamp() + timestamp: this.serverTimestamp() }); } @@ -470,9 +597,9 @@ export class FirestoreService { * @returns Observable of the download URL */ getFileUrl(path: string): Observable { - const storageRef = ref(this.storage, path); + const storageRef = this.ref(this.storage, path); - return from(getDownloadURL(storageRef)).pipe( + return from(this.getDownloadURL(storageRef)).pipe( catchError(error => { console.error(`Error getting download URL for ${path}:`, error); return throwError(() => new Error(`Failed to get download URL: ${error.message}`)); @@ -491,23 +618,23 @@ export class FirestoreService { id: string; data?: any; }>): Observable { - const batch = writeBatch(this.firestore); + const batch = this.writeBatch(this.firestore); operations.forEach(operation => { - const docRef = doc(this.firestore, operation.collection, operation.id); + const docRef = this.doc(this.firestore, operation.collection, operation.id); switch (operation.type) { case 'set': batch.set(docRef, { ...operation.data, - updatedAt: serverTimestamp(), - createdAt: operation.data?.createdAt || serverTimestamp() + updatedAt: this.serverTimestamp(), + createdAt: operation.data?.createdAt || this.serverTimestamp() }); break; case 'update': batch.update(docRef, { ...operation.data, - updatedAt: serverTimestamp() + updatedAt: this.serverTimestamp() }); break; case 'delete': @@ -531,10 +658,10 @@ export class FirestoreService { * @returns Observable of boolean */ documentExists(collectionPath: string, id: string): Observable { - const docRef = doc(this.firestore, collectionPath, id); + const docRef = this.doc(this.firestore, collectionPath, id); - return from(getDoc(docRef)).pipe( - map(snapshot => snapshot.exists()), + return from(this.getDoc(docRef)).pipe( + map((snapshot: unknown) => (snapshot as { exists: () => boolean }).exists()), catchError(error => { console.error(`Error checking document existence in ${collectionPath}:`, error); return of(false); diff --git a/src/app/services/firebase/realtime-db.service.ts b/src/app/services/firebase/realtime-db.service.ts index eaa3326..be20aa9 100644 --- a/src/app/services/firebase/realtime-db.service.ts +++ b/src/app/services/firebase/realtime-db.service.ts @@ -30,7 +30,7 @@ export interface DatabaseItem { providedIn: 'root' }) export class RealtimeDbService { - private readonly db: Database = getDatabase() as Database; + private db: Database | null = null; constructor() { console.warn('RealtimeDbService is deprecated. Please use FirebaseService instead.'); @@ -44,10 +44,17 @@ export class RealtimeDbService { } + private requireDb(): Database { + if (!this.db) { + throw new Error('Realtime Database is not initialized'); + } + return this.db; + } + // Create a new item async create(path: string, data: Omit): Promise { try { - const listRef = ref(this.db, path); + const listRef = ref(this.requireDb(), path); const newItemRef = push(listRef); await set(newItemRef, { ...data, @@ -64,7 +71,7 @@ export class RealtimeDbService { // Set item with custom ID async setItem(path: string, id: string, data: Omit): Promise { try { - const itemRef = ref(this.db, `${path}/${id}`); + const itemRef = ref(this.requireDb(), `${path}/${id}`); await set(itemRef, { ...data, id, @@ -80,7 +87,7 @@ export class RealtimeDbService { // Get a single item by ID async getItem(path: string, id: string): Promise { try { - const itemRef = ref(this.db, `${path}/${id}`); + const itemRef = ref(this.requireDb(), `${path}/${id}`); const snapshot = await get(itemRef); if (snapshot.exists()) { return {id, ...snapshot.val()} as T; @@ -95,7 +102,7 @@ export class RealtimeDbService { // Get all items from a path async getItems(path: string): Promise { try { - const listRef = ref(this.db, path); + const listRef = ref(this.requireDb(), path); const snapshot = await get(listRef); if (snapshot.exists()) { const items: T[] = []; @@ -117,7 +124,7 @@ export class RealtimeDbService { // Update an existing item async updateItem(path: string, id: string, updates: Partial>): Promise { try { - const itemRef = ref(this.db, `${path}/${id}`); + const itemRef = ref(this.requireDb(), `${path}/${id}`); await update(itemRef, { ...updates, updatedAt: Date.now() @@ -131,7 +138,7 @@ export class RealtimeDbService { // Delete an item async deleteItem(path: string, id: string): Promise { try { - const itemRef = ref(this.db, `${path}/${id}`); + const itemRef = ref(this.requireDb(), `${path}/${id}`); await remove(itemRef); } catch (error) { console.error('Error deleting item:', error); @@ -142,7 +149,7 @@ export class RealtimeDbService { // Listen to real-time changes for a single item watchItem(path: string, id: string): Observable { return new Observable(observer => { - const itemRef = ref(this.db, `${path}/${id}`); + const itemRef = ref(this.requireDb(), `${path}/${id}`); const unsubscribe = onValue(itemRef, (snapshot) => { if (snapshot.exists()) { @@ -162,7 +169,7 @@ export class RealtimeDbService { // Listen to real-time changes for a list of items watchItems(path: string): Observable { return new Observable(observer => { - const listRef = ref(this.db, path); + const listRef = ref(this.requireDb(), path); const unsubscribe = onValue(listRef, (snapshot) => { const items: T[] = []; @@ -197,7 +204,7 @@ export class RealtimeDbService { } = {} ): Promise { try { - let queryRef = ref(this.db, path); + const queryRef = ref(this.requireDb(), path); let queryBuilder = query(queryRef); if (options.orderBy) { @@ -255,7 +262,7 @@ export class RealtimeDbService { } = {} ): Observable { return new Observable(observer => { - let queryRef = ref(this.db, path); + const queryRef = ref(this.requireDb(), path); let queryBuilder = query(queryRef); if (options.orderBy) { @@ -304,7 +311,7 @@ export class RealtimeDbService { // Batch operations async batchUpdate(updates: { [path: string]: any }): Promise { try { - const dbRef = ref(this.db); + const dbRef = ref(this.requireDb()); await update(dbRef, updates); } catch (error) { console.error('Error performing batch update:', error); @@ -315,7 +322,7 @@ export class RealtimeDbService { // Check if item exists async exists(path: string, id: string): Promise { try { - const itemRef = ref(this.db, `${path}/${id}`); + const itemRef = ref(this.requireDb(), `${path}/${id}`); const snapshot = await get(itemRef); return snapshot.exists(); } catch (error) { @@ -327,7 +334,7 @@ export class RealtimeDbService { // Get count of items async getCount(path: string): Promise { try { - const listRef = ref(this.db, path); + const listRef = ref(this.requireDb(), path); const snapshot = await get(listRef); return snapshot.size; } catch (error) { diff --git a/src/assets/game/commands/level-1.commands.ts b/src/assets/game/commands/level-1.commands.ts index 93584bb..bb76687 100644 --- a/src/assets/game/commands/level-1.commands.ts +++ b/src/assets/game/commands/level-1.commands.ts @@ -6,7 +6,7 @@ export default function(): CLICommand[] { { name: 'aichat', description: 'Experimental AI ChatBot Service', - execute: (args: string[]) => { + execute: () => { return { status: 200, output: 'aichat > ' diff --git a/src/assets/game/commands/level-2.commands.ts b/src/assets/game/commands/level-2.commands.ts index 93584bb..bb76687 100644 --- a/src/assets/game/commands/level-2.commands.ts +++ b/src/assets/game/commands/level-2.commands.ts @@ -6,7 +6,7 @@ export default function(): CLICommand[] { { name: 'aichat', description: 'Experimental AI ChatBot Service', - execute: (args: string[]) => { + execute: () => { return { status: 200, output: 'aichat > ' diff --git a/src/assets/game/commands/level-3.commands.ts b/src/assets/game/commands/level-3.commands.ts index 93584bb..bb76687 100644 --- a/src/assets/game/commands/level-3.commands.ts +++ b/src/assets/game/commands/level-3.commands.ts @@ -6,7 +6,7 @@ export default function(): CLICommand[] { { name: 'aichat', description: 'Experimental AI ChatBot Service', - execute: (args: string[]) => { + execute: () => { return { status: 200, output: 'aichat > ' diff --git a/src/assets/game/commands/level-4.commands.ts b/src/assets/game/commands/level-4.commands.ts index 93584bb..bb76687 100644 --- a/src/assets/game/commands/level-4.commands.ts +++ b/src/assets/game/commands/level-4.commands.ts @@ -6,7 +6,7 @@ export default function(): CLICommand[] { { name: 'aichat', description: 'Experimental AI ChatBot Service', - execute: (args: string[]) => { + execute: () => { return { status: 200, output: 'aichat > '