feat: Refactor specialty display logic and enhance specialty filtering
- Introduced SpecialtyChips component to manage the display of doctor's specialties with a primary specialty and a count of additional specialties. - Updated ItemDoctor component to utilize SpecialtyChips for better UI presentation. - Enhanced PosterLight and Poster components to display primary specialties and a text line for sub-specialties. - Implemented nextSpecialtyFilter function to improve specialty selection logic in the Content component. - Updated search functionality to allow searching by both doctor name and specialty. - Added new specialties to specialties.json for better coverage. - Created sync-specialties script to synchronize specialties data with the backend during build. - Added tests for new components and helper functions to ensure functionality and reliability.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* قاعدهٔ مشترک نمایش تخصصهای پزشک — منبع واحد کارت و پوستر.
|
||||
*
|
||||
* پزشک میتواند چند تخصص داشته باشد و معمولاً هم دارد: ذخیرهٔ یک زیرتخصص در
|
||||
* بکاند والدهایش را هم مینشاند، پس «جراح گوارش» عملاً یعنی «جراحی عمومی» +
|
||||
* زیرشاخه. چاپ همهٔ نامها پشتسرهم، در موبایل ارتفاع کارت را باد میکند و در
|
||||
* پوستر از کادر ثابت بیرون میزند.
|
||||
*
|
||||
* نمایش در دو جا متفاوت است — کارت `+N` کلیکشدنی دارد و پوستر ندارد — اما
|
||||
* «کدام تخصص اصلی است» یک قاعده بیشتر نیست و اینجا میماند.
|
||||
*/
|
||||
|
||||
const SEPARATOR = " · ";
|
||||
|
||||
/**
|
||||
* تخصص «اصلی» و بقیه.
|
||||
*
|
||||
* ریشه (بدون `parent_id`) اصلی است: عنوانی است که بیمار میشناسد و چون هنگام
|
||||
* ذخیره خودکار اضافه میشود تقریباً همیشه وجود دارد. اگر پزشکی فقط زیرتخصص داشت،
|
||||
* اولین آیتم آرایه.
|
||||
*
|
||||
* @param {Array<{name?: string, parent_id?: string|number|null}>} list
|
||||
* @returns {{primary: object|null, rest: Array<object>}}
|
||||
*/
|
||||
export function splitSpecialties(list) {
|
||||
const items = (list ?? []).filter((s) => s?.name);
|
||||
if (items.length === 0) return { primary: null, rest: [] };
|
||||
|
||||
const primary = items.find((s) => s.parent_id == null) ?? items[0];
|
||||
|
||||
return { primary, rest: items.filter((s) => s !== primary) };
|
||||
}
|
||||
|
||||
/**
|
||||
* زیرتخصصها بهشکل یک خط متنی، بریده روی مرز کلمه با بودجهٔ کاراکتر.
|
||||
*
|
||||
* پوستر کادر ثابت ۱۰۸۰×۱۳۵۰ با `overflow-hidden` دارد؛ هر ردیف اضافه بخشهای
|
||||
* پایین را بیرون میاندازد. برش بر اساس طول متن است نه تعداد ثابت، چون
|
||||
* «جراحی لاپاراسکوپی» و «قلب» یکاندازه جا نمیگیرند.
|
||||
*
|
||||
* @param {Array<{name?: string}>} rest
|
||||
* @param {number} budget حداکثر کاراکترِ خط
|
||||
* @returns {{text: string, hidden: number}}
|
||||
*/
|
||||
export function posterSpecialtyLine(rest, budget = 90) {
|
||||
const names = (rest ?? []).filter((s) => s?.name).map((s) => s.name);
|
||||
const shown = [];
|
||||
let used = 0;
|
||||
|
||||
for (const name of names) {
|
||||
const cost = name.length + (shown.length ? SEPARATOR.length : 0);
|
||||
if (shown.length && used + cost > budget) break;
|
||||
shown.push(name);
|
||||
used += cost;
|
||||
}
|
||||
|
||||
return { text: shown.join(SEPARATOR), hidden: names.length - shown.length };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
|
||||
|
||||
const root = { id: "13", name: "جراحی عمومی", parent_id: null };
|
||||
const gi = { id: "169", name: "جراح گوارش", parent_id: "13" };
|
||||
const thyroid = { id: "168", name: "جراح تیروئید", parent_id: "13" };
|
||||
|
||||
describe("splitSpecialties", () => {
|
||||
it("ریشه اصلی است، حتی اگر اول آرایه نباشد", () => {
|
||||
const { primary, rest } = splitSpecialties([gi, root, thyroid]);
|
||||
|
||||
expect(primary).toBe(root);
|
||||
expect(rest).toEqual([gi, thyroid]);
|
||||
});
|
||||
|
||||
it("بدون ریشه، اولین آیتم اصلی است", () => {
|
||||
const { primary, rest } = splitSpecialties([gi, thyroid]);
|
||||
|
||||
expect(primary).toBe(gi);
|
||||
expect(rest).toEqual([thyroid]);
|
||||
});
|
||||
|
||||
it("تکتخصص، بقیه خالی", () => {
|
||||
const { primary, rest } = splitSpecialties([root]);
|
||||
|
||||
expect(primary).toBe(root);
|
||||
expect(rest).toEqual([]);
|
||||
});
|
||||
|
||||
it("آرایهٔ خالی و ورودی تهی → primary تهی", () => {
|
||||
expect(splitSpecialties([])).toEqual({ primary: null, rest: [] });
|
||||
expect(splitSpecialties(null)).toEqual({ primary: null, rest: [] });
|
||||
expect(splitSpecialties(undefined)).toEqual({ primary: null, rest: [] });
|
||||
});
|
||||
|
||||
it("آیتم بدون نام حذف میشود", () => {
|
||||
const { primary, rest } = splitSpecialties([{ id: "1" }, root, gi]);
|
||||
|
||||
expect(primary).toBe(root);
|
||||
expect(rest).toEqual([gi]);
|
||||
});
|
||||
|
||||
it("parent_id تهیِ رشتهای هم ریشه شمرده میشود", () => {
|
||||
// بکاند parent_id را رشته میدهد؛ برابریِ سست عمدی است تا null و undefined
|
||||
// هر دو ریشه باشند و 0 نباشد.
|
||||
const { primary } = splitSpecialties([
|
||||
{ id: "9", name: "زیرشاخه", parent_id: "13" },
|
||||
{ id: "13", name: "ریشه", parent_id: undefined },
|
||||
]);
|
||||
|
||||
expect(primary.name).toBe("ریشه");
|
||||
});
|
||||
|
||||
it("دو ریشهٔ متفاوت → اولی اصلی، دومی در بقیه", () => {
|
||||
const internal = { id: "2", name: "داخلی", parent_id: null };
|
||||
const { primary, rest } = splitSpecialties([root, internal, gi]);
|
||||
|
||||
expect(primary).toBe(root);
|
||||
expect(rest).toEqual([internal, gi]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("posterSpecialtyLine", () => {
|
||||
it("نامها را با جداکنندهٔ نقطه به هم میچسباند", () => {
|
||||
const { text, hidden } = posterSpecialtyLine([gi, thyroid], 90);
|
||||
|
||||
expect(text).toBe("جراح گوارش · جراح تیروئید");
|
||||
expect(hidden).toBe(0);
|
||||
});
|
||||
|
||||
it("از بودجه که گذشت، بقیه را میشمارد نه میبرد", () => {
|
||||
const { text, hidden } = posterSpecialtyLine([gi, thyroid], 12);
|
||||
|
||||
expect(text).toBe("جراح گوارش");
|
||||
expect(hidden).toBe(1);
|
||||
});
|
||||
|
||||
it("برش روی مرز کلمه است، نه وسط نام", () => {
|
||||
const { text } = posterSpecialtyLine([gi, thyroid], 15);
|
||||
|
||||
expect(text.endsWith("…")).toBe(false);
|
||||
expect(text).toBe("جراح گوارش");
|
||||
});
|
||||
|
||||
it("یک نام بلندتر از کل بودجه، بریده نمیشود", () => {
|
||||
// خط خالی با «و ۱ تخصص دیگر» بدتر از یک نام کمی بلند است.
|
||||
const long = { name: "جراحی لاپاراسکوپی پیشرفتهٔ کبد و مجاری صفراوی" };
|
||||
const { text, hidden } = posterSpecialtyLine([long], 10);
|
||||
|
||||
expect(text).toBe(long.name);
|
||||
expect(hidden).toBe(0);
|
||||
});
|
||||
|
||||
it("آرایهٔ خالی و ورودی تهی → متن تهی و صفر پنهان", () => {
|
||||
expect(posterSpecialtyLine([])).toEqual({ text: "", hidden: 0 });
|
||||
expect(posterSpecialtyLine(null)).toEqual({ text: "", hidden: 0 });
|
||||
});
|
||||
|
||||
it("آیتم بدون نام نه نمایش داده میشود نه شمرده", () => {
|
||||
const { text, hidden } = posterSpecialtyLine([{ id: "1" }, gi], 90);
|
||||
|
||||
expect(text).toBe("جراح گوارش");
|
||||
expect(hidden).toBe(0);
|
||||
});
|
||||
|
||||
it("شش تخصصِ نمونهٔ واقعی از بودجهٔ پیشفرض نمیگذرد", () => {
|
||||
const real = [
|
||||
{ name: "جراحی پلاستیک و زیبایی" },
|
||||
{ name: "جراحی لاپاراسکوپی" },
|
||||
{ name: "جراح تیروئید" },
|
||||
{ name: "جراح گوارش" },
|
||||
{ name: "جراحی سرطانها" },
|
||||
];
|
||||
const { text, hidden } = posterSpecialtyLine(real);
|
||||
|
||||
expect(text.length).toBeLessThanOrEqual(90);
|
||||
expect(hidden).toBe(real.length - text.split(" · ").length);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user