forked from Yara724/api
Merge pull request 'main' (#221) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#221
This commit is contained in:
86
scripts/verify-plate-normalization.mjs
Normal file
86
scripts/verify-plate-normalization.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Quick manual verification for plate normalization.
|
||||
*
|
||||
* Usage: node scripts/verify-plate-normalization.mjs
|
||||
*
|
||||
* Demonstrates that:
|
||||
* 339ه77 (ه + U+200D) -> 339ه77 -> letter ه -> code 15
|
||||
* 339ي77 (Arabic ي) -> 339ی77 -> letter ی -> code 16
|
||||
*/
|
||||
|
||||
// ---------- paste the pure function here so no build needed ----------
|
||||
function normalizePlateText(text) {
|
||||
if (!text) return text;
|
||||
let result = String(text);
|
||||
result = result.normalize("NFKC");
|
||||
result = result.replace(/[\u200C\u200D\u200E\u200F\uFEFF]/g, "");
|
||||
result = result.replace(/\u064A/g, "\u06CC"); // Arabic ي → Persian ی
|
||||
result = result.trim();
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------- the canonical plate-letter → code mapping ----------
|
||||
const PLATE_LETTER_MAP = {
|
||||
الف: 1, ب: 2, پ: 3, ج: 4, د: 5,
|
||||
س: 6, ص: 7, ط: 8, ع: 9, ق: 10,
|
||||
ل: 11, م: 12, ن: 13, و: 14, ه: 15,
|
||||
ی: 16, ک: 17, ژ: 18, ت: 19, ث: 20,
|
||||
ز: 21, ش: 22, ف: 23, گ: 24,
|
||||
};
|
||||
|
||||
function extractAndMap(plateString) {
|
||||
const normalized = normalizePlateText(plateString);
|
||||
|
||||
// Simple parser: digits | letter | digits (left+right digits, 1 letter)
|
||||
const match = normalized.match(/^(\d+)([^\d]+)(\d+)$/);
|
||||
if (!match) return { input: plateString, normalized, error: "no match" };
|
||||
|
||||
const [, leftDigits, letterRaw, rightDigits] = match;
|
||||
const letter = normalizePlateText(letterRaw);
|
||||
const code = PLATE_LETTER_MAP[letter];
|
||||
|
||||
return {
|
||||
input: plateString,
|
||||
normalized,
|
||||
leftDigits,
|
||||
letter,
|
||||
rightDigits,
|
||||
code,
|
||||
ok: code !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- test cases ----------
|
||||
const cases = [
|
||||
{ desc: "ه + ZWJ (the original bug)", plate: "339ه\u200D77" },
|
||||
{ desc: "Arabic ي → Persian ی", plate: "339ي77" },
|
||||
{ desc: "ه + ZWNJ", plate: "339ه\u200C77" },
|
||||
{ desc: "RTL mark around letter", plate: "339\u200Fه\u200E77" },
|
||||
{ desc: "BOM + ZWJ combined", plate: "\uFEFF339ه\u200D77" },
|
||||
{ desc: "clean letter (no junk)", plate: "339ه77" },
|
||||
{ desc: "full plate: الف", plate: "11الف22" },
|
||||
{ desc: "full plate: ی", plate: "55ی99" },
|
||||
];
|
||||
|
||||
console.log("Plate Normalization Verification\n");
|
||||
console.log("=".repeat(72));
|
||||
|
||||
let allPassed = true;
|
||||
for (const { desc, plate } of cases) {
|
||||
const r = extractAndMap(plate);
|
||||
const status = r.ok ? "PASS" : "FAIL";
|
||||
if (!r.ok) allPassed = false;
|
||||
|
||||
console.log(`\n[${status}] ${desc}`);
|
||||
console.log(` Input: ${JSON.stringify(plate)}`);
|
||||
console.log(` Normalized: ${JSON.stringify(r.normalized)}`);
|
||||
if (r.letter) {
|
||||
console.log(` Letter: ${r.letter} (U+${r.letter.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")})`);
|
||||
}
|
||||
console.log(` Code: ${r.code ?? "undefined"}`);
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(72));
|
||||
console.log(allPassed ? "\n All tests PASSED ✓" : "\n Some tests FAILED ✗");
|
||||
process.exit(allPassed ? 0 : 1);
|
||||
@@ -57,6 +57,7 @@ import { JwtModule } from "@nestjs/jwt";
|
||||
import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
||||
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
||||
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -67,6 +68,7 @@ import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||
}),
|
||||
FanavaranAuditModule,
|
||||
FanavaranLookupModule,
|
||||
PlateNormalizerModule,
|
||||
PublicIdModule,
|
||||
UsersModule,
|
||||
RequestManagementModule,
|
||||
|
||||
@@ -156,6 +156,7 @@ import {
|
||||
resolvePartCaptureIndex,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||
|
||||
import {
|
||||
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
|
||||
@@ -352,6 +353,7 @@ export class ClaimRequestManagementService {
|
||||
private readonly fanavaranLookupService: FanavaranLookupService,
|
||||
private readonly fileMakerDbService: FileMakerDbService,
|
||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||
private readonly plateNormalizer: PlateNormalizerService,
|
||||
) {}
|
||||
|
||||
private requiredDocumentKeysV2(isCarBody: boolean): string[] {
|
||||
@@ -503,7 +505,7 @@ export class ClaimRequestManagementService {
|
||||
const ir = Number(irRaw);
|
||||
const leftDigits = Number(leftRaw);
|
||||
const centerDigits = Number(centerRaw);
|
||||
const centerAlphabet = String(alphaRaw || "").trim();
|
||||
const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
|
||||
if (
|
||||
!Number.isFinite(ir) ||
|
||||
!Number.isFinite(leftDigits) ||
|
||||
@@ -530,11 +532,11 @@ export class ClaimRequestManagementService {
|
||||
Number.isFinite(Number(p.leftDigits)) &&
|
||||
Number.isFinite(Number(p.centerDigits)) &&
|
||||
Number.isFinite(Number(p.ir)) &&
|
||||
String(p.centerAlphabet || "").trim()
|
||||
this.plateNormalizer.normalizePlateText(String(p.centerAlphabet || ""))
|
||||
) {
|
||||
return {
|
||||
leftDigits: Number(p.leftDigits),
|
||||
centerAlphabet: String(p.centerAlphabet).trim(),
|
||||
centerAlphabet: this.plateNormalizer.normalizePlateText(String(p.centerAlphabet)),
|
||||
centerDigits: Number(p.centerDigits),
|
||||
ir: Number(p.ir),
|
||||
};
|
||||
@@ -3602,9 +3604,10 @@ export class ClaimRequestManagementService {
|
||||
if (centerAlphabet == null) return null;
|
||||
const raw = String(centerAlphabet).trim();
|
||||
if (!raw) return null;
|
||||
const asNumber = Number(raw);
|
||||
const normalized = this.plateNormalizer.normalizePlateText(raw);
|
||||
const asNumber = Number(normalized);
|
||||
if (Number.isFinite(asNumber)) return asNumber;
|
||||
return FANAVARAN_PLATE_LETTER_CODE[raw] ?? null;
|
||||
return this.plateNormalizer.resolvePlateLetterCode(normalized, FANAVARAN_PLATE_LETTER_CODE);
|
||||
}
|
||||
|
||||
private formatFanavaranPlateNo(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClientModule } from "src/client/client.module";
|
||||
import { PlateDbService } from "src/plates/entites/db-service/plate.db.service";
|
||||
import { SandHubModule } from "src/sand-hub/sand-hub.module";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||
import { PlatesModel, PlatesSchema } from "./entites/schema/plate.schema";
|
||||
import { PlatesService } from "./plates.service";
|
||||
|
||||
@@ -12,6 +13,7 @@ import { PlatesService } from "./plates.service";
|
||||
UsersModule,
|
||||
SandHubModule,
|
||||
ClientModule,
|
||||
PlateNormalizerModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: PlatesModel.name, schema: PlatesSchema },
|
||||
]),
|
||||
|
||||
@@ -7,20 +7,23 @@ import {
|
||||
import { Types } from "mongoose";
|
||||
import { PlateDbService } from "src/plates/entites/db-service/plate.db.service";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||
|
||||
@Injectable()
|
||||
export class PlatesService {
|
||||
constructor(
|
||||
private readonly plateDbService: PlateDbService,
|
||||
private readonly plateNormalizer: PlateNormalizerService,
|
||||
) {}
|
||||
|
||||
async addPlate(currentUser, body: AddPlateDto) {
|
||||
const { plate, nationalCodeOfInsurer } = body;
|
||||
|
||||
const normalizedAlphabet = this.plateNormalizer.normalizePlateText(String(plate.centerAlphabet || ""));
|
||||
const duplicate = await this.plateDbService.findOne({
|
||||
userId: currentUser,
|
||||
leftDigits: plate.leftDigits,
|
||||
centerAlphabet: plate.centerAlphabet,
|
||||
centerAlphabet: normalizedAlphabet,
|
||||
centerDigits: plate.centerDigits,
|
||||
ir: plate.ir,
|
||||
nationalCodeOfInsurer,
|
||||
@@ -31,6 +34,7 @@ export class PlatesService {
|
||||
|
||||
const newPlateData = await this.plateDbService.create({
|
||||
...plate,
|
||||
centerAlphabet: normalizedAlphabet,
|
||||
userId: currentUser,
|
||||
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Types } from "mongoose";
|
||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||
import { SandHubService } from "src/sand-hub/sand-hub.service";
|
||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { BlameRequestDbService } from "./entities/db-service/blame-request.db.service";
|
||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||
@@ -33,6 +34,7 @@ export class InquiryRefreshService {
|
||||
private readonly blameRequestDbService: BlameRequestDbService,
|
||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||
private readonly sandHubService: SandHubService,
|
||||
private readonly plateNormalizer: PlateNormalizerService,
|
||||
) {}
|
||||
|
||||
async reinquiryInquiries(
|
||||
@@ -592,7 +594,7 @@ export class InquiryRefreshService {
|
||||
centerDigits !== undefined &&
|
||||
ir !== undefined
|
||||
) {
|
||||
const plateLetter = String(centerAlphabet).trim();
|
||||
const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet));
|
||||
const parsed: PlateParts = {
|
||||
leftDigits: Number(leftDigits),
|
||||
centerAlphabet: plateLetter,
|
||||
@@ -622,8 +624,7 @@ export class InquiryRefreshService {
|
||||
const ir = Number(irRaw);
|
||||
const leftDigits = Number(leftRaw);
|
||||
const centerDigits = Number(centerRaw);
|
||||
const centerAlphabet = String(alphaRaw || "").trim();
|
||||
|
||||
const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
|
||||
if (
|
||||
!Number.isFinite(ir) ||
|
||||
!Number.isFinite(leftDigits) ||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UsersModule } from "src/users/users.module";
|
||||
import { CronModule } from "src/utils/cron/cron.module";
|
||||
import { PublicIdModule } from "src/utils/public-id/public-id.module";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
|
||||
import { AuthModule } from "src/auth/auth.module";
|
||||
@@ -60,6 +61,7 @@ import { CallCenterBlameV6Controller } from "./call-center-blame-v6.controller";
|
||||
SmsOrchestrationModule,
|
||||
PublicIdModule,
|
||||
HashModule,
|
||||
PlateNormalizerModule,
|
||||
WorkflowStepManagementModule,
|
||||
PlatesModule,
|
||||
MulterModule.register({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { ClientModule } from "src/client/client.module";
|
||||
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||
import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema";
|
||||
import { SandHubService } from "./sand-hub.service";
|
||||
@@ -17,6 +18,7 @@ import { SandHubService } from "./sand-hub.service";
|
||||
useFactory: createHttpModuleOptions,
|
||||
}),
|
||||
SystemSettingsModule,
|
||||
PlateNormalizerModule,
|
||||
ClientModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: SandHubModel.name, schema: SandHubSchema },
|
||||
|
||||
@@ -9,6 +9,9 @@ describe("SandHubService inquiry mocks", () => {
|
||||
isInquiryLive: jest.fn(),
|
||||
getMockCompanyContext: jest.fn(),
|
||||
};
|
||||
const plateNormalizer = {
|
||||
normalizePlateText: (text: string) => text,
|
||||
};
|
||||
|
||||
let service: SandHubService;
|
||||
|
||||
@@ -30,6 +33,7 @@ describe("SandHubService inquiry mocks", () => {
|
||||
httpService as any,
|
||||
sandHubDbService as any,
|
||||
externalInquirySettings as unknown as ExternalInquirySettingsService,
|
||||
plateNormalizer as any,
|
||||
);
|
||||
externalInquirySettings.isInquiryLive.mockResolvedValue(false);
|
||||
externalInquirySettings.getMockCompanyContext.mockResolvedValue({
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
|
||||
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
|
||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||
import type { ExternalInquiryType } from "src/common/types/external-inquiry.types";
|
||||
import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types";
|
||||
import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto";
|
||||
@@ -39,6 +40,7 @@ export class SandHubService {
|
||||
private readonly httpService: HttpService,
|
||||
private readonly sandHubDbService: SandHubDbService,
|
||||
private readonly externalInquirySettings: ExternalInquirySettingsService,
|
||||
private readonly plateNormalizer: PlateNormalizerService,
|
||||
) {}
|
||||
|
||||
private clientRefFrom(options?: SandHubInquiryOptions): string | undefined {
|
||||
@@ -162,8 +164,8 @@ export class SandHubService {
|
||||
nationalCodeOfInsurer: nationalCode,
|
||||
plate: {
|
||||
leftDigits: Number(payload.part1 ?? payload.leftTwoDigits ?? 16),
|
||||
centerAlphabet: String(
|
||||
payload.part2 ?? payload.serialLetter ?? "12",
|
||||
centerAlphabet: this.plateNormalizer.normalizePlateText(
|
||||
String(payload.part2 ?? payload.serialLetter ?? "12"),
|
||||
),
|
||||
centerDigits: Number(payload.part3 ?? payload.threeDigits ?? 498),
|
||||
ir: Number(payload.part4 ?? payload.rightTwoDigits ?? 60),
|
||||
|
||||
8
src/utils/plate-normalizer/plate-normalizer.module.ts
Normal file
8
src/utils/plate-normalizer/plate-normalizer.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PlateNormalizerService } from "./plate-normalizer.service";
|
||||
|
||||
@Module({
|
||||
providers: [PlateNormalizerService],
|
||||
exports: [PlateNormalizerService],
|
||||
})
|
||||
export class PlateNormalizerModule {}
|
||||
79
src/utils/plate-normalizer/plate-normalizer.service.ts
Normal file
79
src/utils/plate-normalizer/plate-normalizer.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* Characters stripped from plate text during normalization.
|
||||
* These invisible Unicode code-points can sneak in via copy-paste
|
||||
* or OCR and break exact-match lookups against the plate letter map.
|
||||
*/
|
||||
const INVISIBLE_CHARS = [
|
||||
"\u200C", // Zero-Width Non-Joiner
|
||||
"\u200D", // Zero-Width Joiner
|
||||
"\u200E", // Left-to-Right Mark
|
||||
"\u200F", // Right-to-Left Mark
|
||||
"\uFEFF", // BOM / Zero-Width No-Break Space
|
||||
];
|
||||
|
||||
const INVISIBLE_RE = new RegExp(`[${INVISIBLE_CHARS.join("")}]`, "g");
|
||||
|
||||
/**
|
||||
* Normalize a Persian license-plate character (or short text) so that
|
||||
* visually-identical Unicode variants collapse to the canonical form
|
||||
* used in the plate letter mapping.
|
||||
*
|
||||
* Steps (in order):
|
||||
* 1. NFKC normalization — folds compatibility equivalents
|
||||
* 2. Strip invisible Unicode characters (ZWJ, ZWNJ, LTR/RTL marks, BOM)
|
||||
* 3. Convert Arabic ي (U+064A) → Persian ی (U+06CC)
|
||||
* 4. Trim leading/trailing whitespace
|
||||
*/
|
||||
export function normalizePlateText(text: string): string {
|
||||
if (!text) return text;
|
||||
|
||||
let result = String(text);
|
||||
|
||||
// 1. Unicode NFKC normalization
|
||||
result = result.normalize("NFKC");
|
||||
|
||||
// 2. Remove invisible characters
|
||||
result = result.replace(INVISIBLE_RE, "");
|
||||
|
||||
// 3. Arabic ي → Persian ی
|
||||
result = result.replace(/\u064A/g, "\u06CC");
|
||||
|
||||
// 4. Trim whitespace
|
||||
result = result.trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlateNormalizerService {
|
||||
private readonly logger = new Logger(PlateNormalizerService.name);
|
||||
|
||||
/** @see module-level {@link normalizePlateText} */
|
||||
normalizePlateText(text: string): string {
|
||||
return normalizePlateText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a plate letter and, if it doesn't resolve in the given
|
||||
* mapping, log the raw Unicode code-points for debugging.
|
||||
*
|
||||
* @returns the numeric code from `mapping`, or `null` when the letter
|
||||
* is unknown after normalization.
|
||||
*/
|
||||
resolvePlateLetterCode(
|
||||
letter: string,
|
||||
mapping: Record<string, number>,
|
||||
): number | null {
|
||||
const normalized = this.normalizePlateText(letter);
|
||||
|
||||
const code = mapping[normalized];
|
||||
if (code !== undefined) return code;
|
||||
|
||||
this.logger.debug(
|
||||
`Unknown plate letter: ${normalized} [${Array.from(normalized).map((c) => `U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}]`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
131
src/utils/plate-normalizer/plate-normalizer.spec.ts
Normal file
131
src/utils/plate-normalizer/plate-normalizer.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { PlateNormalizerService, normalizePlateText } from "./plate-normalizer.service";
|
||||
|
||||
describe("normalizePlateText (pure function)", () => {
|
||||
it("returns unchanged text for a clean plate letter", () => {
|
||||
expect(normalizePlateText("ه")).toBe("ه");
|
||||
expect(normalizePlateText("الف")).toBe("الف");
|
||||
});
|
||||
|
||||
it("strips Zero-Width Joiner (U+200D)", () => {
|
||||
// ه + ZWJ — the exact scenario from the issue
|
||||
const input = "ه\u200D";
|
||||
expect(normalizePlateText(input)).toBe("ه");
|
||||
});
|
||||
|
||||
it("strips Zero-Width Non-Joiner (U+200C)", () => {
|
||||
expect(normalizePlateText("ه\u200C")).toBe("ه");
|
||||
});
|
||||
|
||||
it("strips Left-to-Right Mark (U+200E)", () => {
|
||||
expect(normalizePlateText("ه\u200E")).toBe("ه");
|
||||
});
|
||||
|
||||
it("strips Right-to-Left Mark (U+200F)", () => {
|
||||
expect(normalizePlateText("ه\u200F")).toBe("ه");
|
||||
});
|
||||
|
||||
it("strips BOM (U+FEFF)", () => {
|
||||
expect(normalizePlateText("\uFEFFه")).toBe("ه");
|
||||
});
|
||||
|
||||
it("converts Arabic ي (U+064A) to Persian ی (U+06CC)", () => {
|
||||
expect(normalizePlateText("\u064A")).toBe("\u06CC");
|
||||
});
|
||||
|
||||
it("does not alter already-Persian ی (U+06CC)", () => {
|
||||
expect(normalizePlateText("\u06CC")).toBe("\u06CC");
|
||||
});
|
||||
|
||||
it("handles a full plate string: 339ه\u200D77 → 339ه77", () => {
|
||||
expect(normalizePlateText("339ه\u200D77")).toBe("339ه77");
|
||||
});
|
||||
|
||||
it("handles multiple invisible chars interleaved", () => {
|
||||
const input = "\u200F339\u200Cه\u200D\uFEFF77\u200E";
|
||||
expect(normalizePlateText(input)).toBe("339ه77");
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(normalizePlateText(" ه ")).toBe("ه");
|
||||
});
|
||||
|
||||
it("applies NFKC normalization", () => {
|
||||
// Full-width digit 3 (U+FF13) → ASCII 3
|
||||
expect(normalizePlateText("3")).toBe("3");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(normalizePlateText("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlateNormalizerService", () => {
|
||||
let service: PlateNormalizerService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PlateNormalizerService();
|
||||
});
|
||||
|
||||
describe("normalizePlateText", () => {
|
||||
it("delegates to the pure function", () => {
|
||||
expect(service.normalizePlateText("ه\u200D")).toBe("ه");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePlateLetterCode", () => {
|
||||
const PLATE_LETTER_MAP: Record<string, number> = {
|
||||
"\u0627\u0644\u0641": 1, // الف
|
||||
"\u0628": 2,
|
||||
"\u067E": 3,
|
||||
"\u062C": 4,
|
||||
"\u062F": 5,
|
||||
"\u0633": 6,
|
||||
"\u0635": 7,
|
||||
"\u0637": 8,
|
||||
"\u0639": 9,
|
||||
"\u0642": 10,
|
||||
"\u0644": 11,
|
||||
"\u0645": 12,
|
||||
"\u0646": 13,
|
||||
"\u0648": 14,
|
||||
"\u0647": 15, // ه
|
||||
"\u06CC": 16, // ی
|
||||
"\u06A9": 17,
|
||||
"\u0698": 18,
|
||||
"\u062A": 19,
|
||||
"\u062B": 20,
|
||||
"\u0632": 21,
|
||||
"\u0634": 22,
|
||||
"\u0641": 23,
|
||||
"\u06AF": 24,
|
||||
};
|
||||
|
||||
it("returns 15 for clean ه", () => {
|
||||
expect(service.resolvePlateLetterCode("ه", PLATE_LETTER_MAP)).toBe(15);
|
||||
});
|
||||
|
||||
it("returns 15 for ه + ZWJ (the bug scenario)", () => {
|
||||
expect(service.resolvePlateLetterCode("ه\u200D", PLATE_LETTER_MAP)).toBe(
|
||||
15,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 16 for Arabic ي (mapped to Persian ی)", () => {
|
||||
expect(
|
||||
service.resolvePlateLetterCode("\u064A", PLATE_LETTER_MAP),
|
||||
).toBe(16);
|
||||
});
|
||||
|
||||
it("returns null for an unknown letter and logs it", () => {
|
||||
const spy = jest.spyOn(
|
||||
(service as any).logger,
|
||||
"debug",
|
||||
);
|
||||
expect(service.resolvePlateLetterCode("؟", PLATE_LETTER_MAP)).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unknown plate letter"),
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user