diff --git a/scripts/eval/varyant-denetim.ts b/scripts/eval/varyant-denetim.ts new file mode 100644 index 0000000..310a67b --- /dev/null +++ b/scripts/eval/varyant-denetim.ts @@ -0,0 +1,79 @@ +/** + * Program varyant etiketinin MEKANİK testi — parasız, anahtarsız, salt okur. + * + * pnpm tsx scripts/eval/varyant-denetim.ts + * + * react-server koşulu gerekmez: db.ts ve program-varyant.ts saf/sqlite. + * Doğrulananlar: (1) varyantEtiketi birim kuralları, (2) Başkent Tıp'ın 6 + * satırının etiketleri birbirinden farklı ve hiçbiri null, (3) dilimAra'nın + * döndürdüğü her satırda `varyant` tanımlı (SELECT_COLS tüm yolu kapsıyor), + * (4) devlet/Türkçe/örgün programda null. data/yokatlas.db'ye yazılmaz. + * Beklenti tutmazsa çıkış kodu 1. + */ +import { dilimAra, programGetir } from "../../src/lib/db"; +import { varyantEtiketi, type VaryantGirdisi } from "../../src/lib/program-varyant"; + +let hata = 0; +function bekle(ad: string, kosul: boolean, detay = ""): void { + console.log(`${kosul ? "✔" : "✘"} ${ad}${detay ? ` — ${detay}` : ""}`); + if (!kosul) hata++; +} + +// 1. Birim: etiket kuralları +const birim: [VaryantGirdisi, string | null][] = [ + [{ bursOrani: "Burslu", ogrenimDili: "İngilizce", ogrenimTuru: "Örgün Öğretim" }, "Burslu · İngilizce"], + [{ bursOrani: null, ogrenimDili: "Türkçe", ogrenimTuru: "Örgün Öğretim" }, null], + [{ bursOrani: "Ücretli", ogrenimDili: "Türkçe", ogrenimTuru: "İkinci Öğretim" }, "Ücretli · İkinci Öğretim"], + [{ bursOrani: "%50 İndirimli", ogrenimDili: null, ogrenimTuru: null }, "%50 İndirimli"], + // DB'de burs_orani boş string de geliyor (devlet); null gibi davranır + [{ bursOrani: "", ogrenimDili: "Türkçe", ogrenimTuru: "Uzaktan Öğretim" }, "Uzaktan Öğretim"], +]; +for (const [girdi, beklenen] of birim) { + const sonuc = varyantEtiketi(girdi); + bekle( + `birim ${JSON.stringify(girdi)}`, + sonuc === beklenen, + `beklenen ${JSON.stringify(beklenen)}, gelen ${JSON.stringify(sonuc)}`, + ); +} + +// 2. Başkent Tıp ×6: isim hepsinde "Tıp", varyant hepsinde farklı +const BASKENT_TIP = ["200610657", "200610666", "200611488", "200690357", "200690358", "200690359"]; +const baskent = BASKENT_TIP.map((id) => ({ id, p: programGetir(id) })); +for (const { id, p } of baskent) { + bekle( + `programGetir(${id}) varyant dolu`, + p != null && p.varyant != null, + p ? `${p.universite} / ${p.isim} → ${p.varyant}` : "bulunamadı", + ); +} +const etiketler = baskent.map(({ p }) => p?.varyant ?? null); +bekle( + "Başkent Tıp 6 varyant birbirinden farklı", + new Set(etiketler).size === BASKENT_TIP.length, + etiketler.join(" | "), +); + +// 3. dilimAra yolu: her satırda varyant tanımlı (string ya da null) +const { programlar } = dilimAra(100000, "say", "dengeli", { limit: 50 }); +const tanimsiz = programlar.filter( + (p) => !("varyant" in p) || (p.varyant !== null && typeof p.varyant !== "string"), +); +bekle( + `dilimAra 50 satırda varyant tanımlı (${programlar.length} satır)`, + programlar.length > 0 && tanimsiz.length === 0, + tanimsiz.map((p) => p.id).join(","), +); +const etiketli = programlar.filter((p) => p.varyant).length; +console.log(` (bilgi) dilimAra örneğinde etiketli satır: ${etiketli}/${programlar.length}`); + +// 4. Devlet / Türkçe / örgün → null (Marmara Hukuk) +const marmara = programGetir("107210332"); +bekle( + "107210332 Marmara Hukuk varyant null", + marmara != null && marmara.varyant === null, + marmara ? `${marmara.universite} / ${marmara.isim} → ${JSON.stringify(marmara.varyant)}` : "bulunamadı", +); + +console.log(hata === 0 ? "\nVaryant denetimi: TEMİZ" : `\nVaryant denetimi: ${hata} hata`); +process.exit(hata === 0 ? 0 : 1); diff --git a/src/features/katalog/components/bolum-program-listesi.tsx b/src/features/katalog/components/bolum-program-listesi.tsx index 9083819..2d80712 100644 --- a/src/features/katalog/components/bolum-program-listesi.tsx +++ b/src/features/katalog/components/bolum-program-listesi.tsx @@ -22,6 +22,7 @@ import { SATIR_BUTON_BASLIK, SATIR_BUTON_HUCRE, SATIR_BUTON_KABI, + SATIR_CIP, SATIR_GRUP, SATIR_IKINCIL_LINK, SATIR_META, @@ -242,6 +243,10 @@ export function BolumProgramListesi({ )} + {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */} + {p.varyant ? ( + {p.varyant} + ) : null} {p.il ? ( {TUR_ETIKET[p.tur] ?? p.tur} + {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */} + {p.varyant ? ( + {p.varyant} + ) : null} {p.sure ? ( {p.sure} yıl ) : null} diff --git a/src/features/liste/components/liste-cekmecesi.tsx b/src/features/liste/components/liste-cekmecesi.tsx index 6db74f5..3104dc6 100644 --- a/src/features/liste/components/liste-cekmecesi.tsx +++ b/src/features/liste/components/liste-cekmecesi.tsx @@ -452,6 +452,10 @@ function CekmeceSatiri({ > {trBaslikDuzeni(tercih.universite)} + {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder */} + {tercih.varyant ? ( + · {tercih.varyant} + ) : null} {tercih.risk || tercih.efektifSira != null ? ( diff --git a/src/features/liste/components/program-ekle-butonu.tsx b/src/features/liste/components/program-ekle-butonu.tsx index 10ba5ce..8275d43 100644 --- a/src/features/liste/components/program-ekle-butonu.tsx +++ b/src/features/liste/components/program-ekle-butonu.tsx @@ -28,6 +28,7 @@ type ProgramOzet = Pick< | "unitur" | "tur" | "efektifSira" + | "varyant" >; const PROFILSIZ_EKLEME_TOAST_ID = "profilsiz-ekleme"; diff --git a/src/features/liste/components/program-tablosu.tsx b/src/features/liste/components/program-tablosu.tsx index 1ba6711..d720c5a 100644 --- a/src/features/liste/components/program-tablosu.tsx +++ b/src/features/liste/components/program-tablosu.tsx @@ -55,6 +55,7 @@ import { SATIR_BUTON_HUCRE, SATIR_BUTON_KABI, SATIR_ALT_GRUP, + SATIR_CIP, SATIR_IKINCIL_LINK, SATIR_META, SATIR_MOBIL_SAYI, @@ -373,6 +374,10 @@ function ProgramSatiri({ unitur={p.unitur} onClick={(e) => e.stopPropagation()} /> + {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */} + {p.varyant ? ( + {p.varyant} + ) : null} {p.il ? ( ; kaynak: ManuelKaynak; adaySira?: number; @@ -271,11 +272,16 @@ export function programEkleIste(opts: { } const profil = profilsiz ? null : (durum.profil ?? tercihProfiliOku()); - const tercih = programdanManuelTercih( - program, - adaySira ?? profil?.sira ?? null, - kaynak, - ); + // programdanManuelTercih (liste-store) varyantı bilmez; etiket burada + // kopyalanır ki çekmece aynı adlı satırları ayırt edebilsin. + const tercih = { + ...programdanManuelTercih( + program, + adaySira ?? profil?.sira ?? null, + kaynak, + ), + varyant: program.varyant ?? null, + }; const ok = ekle(tercih); if (!ok) { return { diff --git a/src/features/liste/liste-dogrula.ts b/src/features/liste/liste-dogrula.ts index 2396a66..c63494d 100644 --- a/src/features/liste/liste-dogrula.ts +++ b/src/features/liste/liste-dogrula.ts @@ -30,6 +30,8 @@ export type ManuelTercih = { /** Ekleme / profil anındaki aday sıralamasına göre risk */ risk: RiskSeviyesi | null; kaynak?: ManuelKaynak; + /** Burs/dil/öğretim türü etiketi (Program.varyant); eski kayıtlarda yok */ + varyant?: string | null; }; /** Güvenilmeyen JSON'u (localStorage veya sunucu kaydı) listeye süzer. */ @@ -66,6 +68,7 @@ export function dogrula(ham: unknown): ManuelTercih[] { : null, risk, kaynak, + varyant: typeof kayit.varyant === "string" ? kayit.varyant : null, }); if (sonuc.length >= MANUEL_LISTE_MAX) break; } diff --git a/src/features/liste/liste-queries.ts b/src/features/liste/liste-queries.ts index d716a2e..4f95cb7 100644 --- a/src/features/liste/liste-queries.ts +++ b/src/features/liste/liste-queries.ts @@ -25,6 +25,7 @@ export function programOzetle(p: Program): ProgramListeSatiri { efektifSira: p.efektifSira, kontenjanSon: p.kontenjanSon, yerlesenSon: p.yerlesenSon, + varyant: p.varyant, }; } diff --git a/src/features/rapor/components/rapor-listesi.tsx b/src/features/rapor/components/rapor-listesi.tsx index 02f01a2..29cde2b 100644 --- a/src/features/rapor/components/rapor-listesi.tsx +++ b/src/features/rapor/components/rapor-listesi.tsx @@ -41,6 +41,7 @@ import { SATIR_BUTON_HUCRE, SATIR_DUGME_BOYU, SATIR_ALT_GRUP, + SATIR_CIP, SATIR_IKINCIL_LINK, SATIR_META, SATIR_MOBIL_SAYI, @@ -267,6 +268,10 @@ export function RaporListesi({ unitur={p?.unitur} onClick={(e) => e.stopPropagation()} /> + {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */} + {p?.varyant ? ( + {p.varyant} + ) : null} {p?.il ? ( ; }; diff --git a/src/lib/db.ts b/src/lib/db.ts index 5433355..f6d24f9 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -5,6 +5,7 @@ import Database from "better-sqlite3"; import { KATEGORILER, kategoriBul } from "./kategoriler"; import { EFEKTIF_SIRA_SQL, ILK_YIL, SON_YIL, YILLAR } from "./veri-yillari"; import { turkishSlugify } from "./slug"; +import { varyantEtiketi } from "./program-varyant"; import { PUAN_TURLERI, type ArsivProgram, @@ -37,20 +38,33 @@ export function getDb(): Database.Database { // Yıl ekli kolonlar yalnız burada, `YILLAR`'dan üretilerek adlandırılır; DB // dışına yılsız `Program` çıkar. SELECT_COLS ile okunan her satır // `programSatiri` eşleyicisinden geçmelidir (ham satır `Program` değildir). +// Burs/dil/öğretim türü `program_ozellik`'ten ilişkili alt sorguyla gelir +// (id PRIMARY KEY). Bütün SELECT_COLS kullanımları takma adsız `FROM programs` +// olduğu için `programs.id` çözümlenir; `programs_arsiv` (ARSIV_SELECT) bu +// kolonları almaz (arşivde özellik yok). Tablo yokluğu koruması gerekmez: +// data/yokatlas.db depoda ve Docker seed'inde tabloyla gelir. export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure, ${YILLAR.map((yil) => `sira${yil} AS s${yil}`).join(", ")}, - puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon`; + puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon, + (SELECT burs_orani FROM program_ozellik o WHERE o.id = programs.id) AS bursOrani, + (SELECT ogrenim_dili FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimDili, + (SELECT ogrenim_turu FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimTuru`; // Son yılın sıralaması yoksa (az yerleşen/yeni program) bir önceki yıla düşer. // İfade indeks sözleşmesidir; tek kaynağı lib/veri-yillari.ts. export const EFEKTIF_SIRA = EFEKTIF_SIRA_SQL; -/** SELECT_COLS ile okunan ham satır: sıra kolonları `s` takma adıyla gelir. */ +/** SELECT_COLS ile okunan ham satır: sıra kolonları `s` takma adıyla, + * varyant ise üç ham özellik alanı olarak gelir. */ export type ProgramHamSatiri = Omit< Program, - "siraGecmisi" | "efektifSira" | "siraSon" + "siraGecmisi" | "efektifSira" | "siraSon" | "varyant" > & - Record<`s${number}`, number | null>; + Record<`s${number}`, number | null> & { + bursOrani: string | null; + ogrenimDili: string | null; + ogrenimTuru: string | null; + }; /** Ham DB satırını yılsız `Program`'a çevirir. */ export function programSatiri(row: ProgramHamSatiri): Program { @@ -72,6 +86,7 @@ export function programSatiri(row: ProgramHamSatiri): Program { puanSon: row.puanSon, kontenjanSon: row.kontenjanSon, yerlesenSon: row.yerlesenSon, + varyant: varyantEtiketi(row), }; } diff --git a/src/lib/program-varyant.ts b/src/lib/program-varyant.ts new file mode 100644 index 0000000..c53524f --- /dev/null +++ b/src/lib/program-varyant.ts @@ -0,0 +1,40 @@ +// Program varyant etiketi — saf, DB'siz. `programs.isim` burs/dil/öğretim +// türünü taşımaz (Başkent Tıp 6 satırda da "Tıp"); ayırt edici alanlar +// `program_ozellik.burs_orani / ogrenim_dili / ogrenim_turu`. Bu modül o üç +// alanı satır altında gösterilecek tek etikete çevirir; db.ts `programSatiri` +// ve rapor-queries (kayıtlı rapor) aynı fonksiyonu kullanır. + +export type VaryantGirdisi = { + /** "Burslu" | "%50 İndirimli" | "%25 İndirimli" | "Ücretli"; devlette null/boş */ + bursOrani: string | null; + /** "Türkçe" | "İngilizce" | "İngilizce (%30)" | "Almanca" … */ + ogrenimDili: string | null; + /** "Örgün Öğretim" | "Uzaktan Öğretim" | "Açık Öğretim" | "UOLP" … */ + ogrenimTuru: string | null; +}; + +const VARSAYILAN_DIL = "Türkçe"; +const VARSAYILAN_OGRETIM = "Örgün Öğretim"; +const AYRAC = " · "; + +function dolu(deger: string | null | undefined): string | null { + const metin = deger?.trim(); + return metin ? metin : null; +} + +/** + * Satır altı etiketi: "Burslu · İngilizce", "%50 İndirimli", "İkinci Öğretim", + * "Ücretli · Uzaktan Öğretim". Sıra: burs (varsa, DB'deki hâliyle), dil + * (yalnız Türkçe değilse), öğretim türü (yalnız örgün değilse). Üçü de + * varsayılansa (devlet / Türkçe / örgün) null — satırda hiçbir şey çizilmez. + */ +export function varyantEtiketi(g: VaryantGirdisi): string | null { + const parcalar: string[] = []; + const burs = dolu(g.bursOrani); + if (burs) parcalar.push(burs); + const dil = dolu(g.ogrenimDili); + if (dil && dil !== VARSAYILAN_DIL) parcalar.push(dil); + const ogretim = dolu(g.ogrenimTuru); + if (ogretim && ogretim !== VARSAYILAN_OGRETIM) parcalar.push(ogretim); + return parcalar.length > 0 ? parcalar.join(AYRAC) : null; +} diff --git a/src/lib/rapor-havuzu.ts b/src/lib/rapor-havuzu.ts index 8e0126e..c4d7ad7 100644 --- a/src/lib/rapor-havuzu.ts +++ b/src/lib/rapor-havuzu.ts @@ -433,6 +433,9 @@ export function havuzuKompaktJson(havuz: AdayProgram[]): string { unitur: p.unitur, il: p.il, dilim: p.dilim, + // Burs/dil/öğretim türü: model aynı adlı satırları (Başkent Tıp + // burslu / %50 / ücretli) ayırt etsin, gerekçede "burslu" diyebilsin + ...(p.varyant ? { varyant: p.varyant } : {}), // Anahtarlar yıl taşır (y, kontenjan): model rakamın hangi // yıla ait olduğunu anahtardan okur. sira: Object.fromEntries( diff --git a/src/types/yokatlas.ts b/src/types/yokatlas.ts index d480ba7..0c395d2 100644 --- a/src/types/yokatlas.ts +++ b/src/types/yokatlas.ts @@ -21,6 +21,13 @@ export type Program = { puanSon: number | null; kontenjanSon: number | null; yerlesenSon: number | null; + /** + * Burs / dil / öğretim türü etiketi ("Burslu · İngilizce", "%50 İndirimli", + * "Uzaktan Öğretim"); `program_ozellik`'ten `varyantEtiketi` ile üretilir + * (lib/program-varyant). Devlet/Türkçe/örgün programda null. `isim` aynı + * olan satırları (Başkent Tıp ×6) listede ayırt eden tek alan. + */ + varyant: string | null; }; export const PUAN_TURLERI = {