mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
feat: add anemic model detection and refactor hardcoded values (v0.9.0)
This commit is contained in:
372
packages/guardian/tests/AnemicModelDetector.test.ts
Normal file
372
packages/guardian/tests/AnemicModelDetector.test.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { AnemicModelDetector } from "../src/infrastructure/analyzers/AnemicModelDetector"
|
||||
|
||||
describe("AnemicModelDetector", () => {
|
||||
let detector: AnemicModelDetector
|
||||
|
||||
beforeEach(() => {
|
||||
detector = new AnemicModelDetector()
|
||||
})
|
||||
|
||||
describe("detectAnemicModels", () => {
|
||||
it("should detect class with only getters and setters", () => {
|
||||
const code = `
|
||||
class Order {
|
||||
private status: string
|
||||
private total: number
|
||||
|
||||
getStatus(): string {
|
||||
return this.status
|
||||
}
|
||||
|
||||
setStatus(status: string): void {
|
||||
this.status = status
|
||||
}
|
||||
|
||||
getTotal(): number {
|
||||
return this.total
|
||||
}
|
||||
|
||||
setTotal(total: number): void {
|
||||
this.total = total
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/entities/Order.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].className).toBe("Order")
|
||||
expect(violations[0].methodCount).toBeGreaterThan(0)
|
||||
expect(violations[0].propertyCount).toBeGreaterThan(0)
|
||||
expect(violations[0].getMessage()).toContain("Order")
|
||||
})
|
||||
|
||||
it("should detect class with public setters", () => {
|
||||
const code = `
|
||||
class User {
|
||||
private email: string
|
||||
private password: string
|
||||
|
||||
public setEmail(email: string): void {
|
||||
this.email = email
|
||||
}
|
||||
|
||||
public getEmail(): string {
|
||||
return this.email
|
||||
}
|
||||
|
||||
public setPassword(password: string): void {
|
||||
this.password = password
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/entities/User.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].className).toBe("User")
|
||||
expect(violations[0].hasPublicSetters).toBe(true)
|
||||
})
|
||||
|
||||
it("should not detect rich domain model with business logic", () => {
|
||||
const code = `
|
||||
class Order {
|
||||
private readonly id: string
|
||||
private status: OrderStatus
|
||||
private items: OrderItem[]
|
||||
|
||||
public approve(): void {
|
||||
if (!this.canBeApproved()) {
|
||||
throw new Error("Cannot approve")
|
||||
}
|
||||
this.status = OrderStatus.APPROVED
|
||||
}
|
||||
|
||||
public reject(reason: string): void {
|
||||
if (!this.canBeRejected()) {
|
||||
throw new Error("Cannot reject")
|
||||
}
|
||||
this.status = OrderStatus.REJECTED
|
||||
}
|
||||
|
||||
public addItem(item: OrderItem): void {
|
||||
if (this.isApproved()) {
|
||||
throw new Error("Cannot modify approved order")
|
||||
}
|
||||
this.items.push(item)
|
||||
}
|
||||
|
||||
public calculateTotal(): Money {
|
||||
return this.items.reduce((sum, item) => sum.add(item.getPrice()), Money.zero())
|
||||
}
|
||||
|
||||
public getStatus(): OrderStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
private canBeApproved(): boolean {
|
||||
return this.status === OrderStatus.PENDING
|
||||
}
|
||||
|
||||
private canBeRejected(): boolean {
|
||||
return this.status === OrderStatus.PENDING
|
||||
}
|
||||
|
||||
private isApproved(): boolean {
|
||||
return this.status === OrderStatus.APPROVED
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/entities/Order.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should not analyze files outside domain layer", () => {
|
||||
const code = `
|
||||
class OrderDto {
|
||||
getStatus(): string {
|
||||
return this.status
|
||||
}
|
||||
|
||||
setStatus(status: string): void {
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/application/dtos/OrderDto.ts",
|
||||
"application",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should not analyze DTO files", () => {
|
||||
const code = `
|
||||
class UserDto {
|
||||
private email: string
|
||||
|
||||
getEmail(): string {
|
||||
return this.email
|
||||
}
|
||||
|
||||
setEmail(email: string): void {
|
||||
this.email = email
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/dtos/UserDto.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should not analyze test files", () => {
|
||||
const code = `
|
||||
class Order {
|
||||
getStatus(): string {
|
||||
return this.status
|
||||
}
|
||||
|
||||
setStatus(status: string): void {
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/entities/Order.test.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should detect anemic model in entities folder", () => {
|
||||
const code = `
|
||||
class Product {
|
||||
private name: string
|
||||
private price: number
|
||||
|
||||
getName(): string {
|
||||
return this.name
|
||||
}
|
||||
|
||||
setName(name: string): void {
|
||||
this.name = name
|
||||
}
|
||||
|
||||
getPrice(): number {
|
||||
return this.price
|
||||
}
|
||||
|
||||
setPrice(price: number): void {
|
||||
this.price = price
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/entities/Product.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].className).toBe("Product")
|
||||
})
|
||||
|
||||
it("should detect anemic model in aggregates folder", () => {
|
||||
const code = `
|
||||
class Customer {
|
||||
private email: string
|
||||
|
||||
getEmail(): string {
|
||||
return this.email
|
||||
}
|
||||
|
||||
setEmail(email: string): void {
|
||||
this.email = email
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/aggregates/customer/Customer.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].className).toBe("Customer")
|
||||
})
|
||||
|
||||
it("should not detect class with good method-to-property ratio", () => {
|
||||
const code = `
|
||||
class Account {
|
||||
private balance: number
|
||||
private isActive: boolean
|
||||
|
||||
public deposit(amount: number): void {
|
||||
if (amount <= 0) throw new Error("Invalid amount")
|
||||
this.balance += amount
|
||||
}
|
||||
|
||||
public withdraw(amount: number): void {
|
||||
if (amount > this.balance) throw new Error("Insufficient funds")
|
||||
this.balance -= amount
|
||||
}
|
||||
|
||||
public activate(): void {
|
||||
this.isActive = true
|
||||
}
|
||||
|
||||
public deactivate(): void {
|
||||
this.isActive = false
|
||||
}
|
||||
|
||||
public getBalance(): number {
|
||||
return this.balance
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/entities/Account.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle class with no properties or methods", () => {
|
||||
const code = `
|
||||
class EmptyEntity {
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/entities/EmptyEntity.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should detect multiple anemic classes in one file", () => {
|
||||
const code = `
|
||||
class Order {
|
||||
getStatus() { return this.status }
|
||||
setStatus(status: string) { this.status = status }
|
||||
}
|
||||
|
||||
class Item {
|
||||
getPrice() { return this.price }
|
||||
setPrice(price: number) { this.price = price }
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/entities/Models.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(2)
|
||||
expect(violations[0].className).toBe("Order")
|
||||
expect(violations[1].className).toBe("Item")
|
||||
})
|
||||
|
||||
it("should provide correct violation details", () => {
|
||||
const code = `
|
||||
class Payment {
|
||||
private amount: number
|
||||
private currency: string
|
||||
|
||||
getAmount(): number {
|
||||
return this.amount
|
||||
}
|
||||
|
||||
setAmount(amount: number): void {
|
||||
this.amount = amount
|
||||
}
|
||||
|
||||
getCurrency(): string {
|
||||
return this.currency
|
||||
}
|
||||
|
||||
setCurrency(currency: string): void {
|
||||
this.currency = currency
|
||||
}
|
||||
}
|
||||
`
|
||||
const violations = detector.detectAnemicModels(
|
||||
code,
|
||||
"src/domain/entities/Payment.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(violations).toHaveLength(1)
|
||||
const violation = violations[0]
|
||||
expect(violation.className).toBe("Payment")
|
||||
expect(violation.filePath).toBe("src/domain/entities/Payment.ts")
|
||||
expect(violation.layer).toBe("domain")
|
||||
expect(violation.line).toBeGreaterThan(0)
|
||||
expect(violation.getMessage()).toContain("Payment")
|
||||
expect(violation.getSuggestion()).toContain("business")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,7 @@ describe("AnalyzeProject E2E", () => {
|
||||
expect(Array.isArray(result.dependencyDirectionViolations)).toBe(true)
|
||||
expect(Array.isArray(result.repositoryPatternViolations)).toBe(true)
|
||||
expect(Array.isArray(result.aggregateBoundaryViolations)).toBe(true)
|
||||
expect(Array.isArray(result.anemicModelViolations)).toBe(true)
|
||||
})
|
||||
|
||||
it("should respect exclude patterns", async () => {
|
||||
@@ -65,7 +66,8 @@ describe("AnalyzeProject E2E", () => {
|
||||
result.entityExposureViolations.length +
|
||||
result.dependencyDirectionViolations.length +
|
||||
result.repositoryPatternViolations.length +
|
||||
result.aggregateBoundaryViolations.length
|
||||
result.aggregateBoundaryViolations.length +
|
||||
result.anemicModelViolations.length
|
||||
|
||||
expect(totalViolations).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -82,6 +84,7 @@ describe("AnalyzeProject E2E", () => {
|
||||
expect(result.entityExposureViolations.length).toBe(0)
|
||||
expect(result.dependencyDirectionViolations.length).toBe(0)
|
||||
expect(result.circularDependencyViolations.length).toBe(0)
|
||||
expect(result.anemicModelViolations.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should have no dependency direction violations", async () => {
|
||||
|
||||
Reference in New Issue
Block a user