@things-factory/board-service 10.0.0-beta.85 → 10.0.0-beta.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist-server/controllers/headless-pool-for-board.d.ts +8 -0
  2. package/dist-server/controllers/headless-pool-for-board.js +14 -4
  3. package/dist-server/controllers/headless-pool-for-board.js.map +1 -1
  4. package/dist-server/controllers/headless-pool-for-label.js +14 -4
  5. package/dist-server/controllers/headless-pool-for-label.js.map +1 -1
  6. package/dist-server/controllers/index.d.ts +1 -10
  7. package/dist-server/controllers/label-command.d.ts +22 -11
  8. package/dist-server/controllers/label-command.js +113 -14
  9. package/dist-server/controllers/label-command.js.map +1 -1
  10. package/dist-server/controllers/label-command.test.d.ts +13 -0
  11. package/dist-server/controllers/label-command.test.js +211 -0
  12. package/dist-server/controllers/label-command.test.js.map +1 -0
  13. package/dist-server/service/board/board-mutation.js +16 -39
  14. package/dist-server/service/board/board-mutation.js.map +1 -1
  15. package/dist-server/service/board/board.d.ts +13 -0
  16. package/dist-server/service/board/board.js +5 -0
  17. package/dist-server/service/board/board.js.map +1 -1
  18. package/dist-server/service/board/thumbnail-scheduler.d.ts +41 -0
  19. package/dist-server/service/board/thumbnail-scheduler.js +156 -0
  20. package/dist-server/service/board/thumbnail-scheduler.js.map +1 -0
  21. package/dist-server/service/board/thumbnail-scheduler.test.d.ts +14 -0
  22. package/dist-server/service/board/thumbnail-scheduler.test.js +293 -0
  23. package/dist-server/service/board/thumbnail-scheduler.test.js.map +1 -0
  24. package/dist-server/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +6 -6
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Board Service Headless Pool
3
3
  * Using the unified headless pool system from @things-factory/shell
4
+ *
5
+ * 운영 환경 (GPU 없는 EC2 등) 에서는 idle 브라우저 상주 부담이 크다. min=0 으로 두면
6
+ * 사용 안 할 때 메모리 0, 첫 요청 시에만 spawn (~1-2 초 cold start). thumbnail 은
7
+ * fire-and-forget 백그라운드라 cold start 가 사용자 경험에 영향 없다.
8
+ *
9
+ * 환경 변수로 운영자가 조정:
10
+ * HEADLESS_POOL_BOARD_MIN (default 0 — 메모리 절약)
11
+ * HEADLESS_POOL_BOARD_MAX (default 10)
4
12
  */
5
13
  /**
6
14
  * Get the board headless pool
@@ -2,14 +2,24 @@
2
2
  /**
3
3
  * Board Service Headless Pool
4
4
  * Using the unified headless pool system from @things-factory/shell
5
+ *
6
+ * 운영 환경 (GPU 없는 EC2 등) 에서는 idle 브라우저 상주 부담이 크다. min=0 으로 두면
7
+ * 사용 안 할 때 메모리 0, 첫 요청 시에만 spawn (~1-2 초 cold start). thumbnail 은
8
+ * fire-and-forget 백그라운드라 cold start 가 사용자 경험에 영향 없다.
9
+ *
10
+ * 환경 변수로 운영자가 조정:
11
+ * HEADLESS_POOL_BOARD_MIN (default 0 — 메모리 절약)
12
+ * HEADLESS_POOL_BOARD_MAX (default 10)
5
13
  */
6
14
  Object.defineProperty(exports, "__esModule", { value: true });
7
15
  exports.getHeadlessPool = getHeadlessPool;
8
16
  const shell_1 = require("@things-factory/shell");
17
+ const POOL_MIN = parseInt(process.env.HEADLESS_POOL_BOARD_MIN ?? '0', 10);
18
+ const POOL_MAX = parseInt(process.env.HEADLESS_POOL_BOARD_MAX ?? '10', 10);
9
19
  // Create the board pool instance
10
20
  const boardPool = (0, shell_1.getOrCreateHeadlessPool)('board', {
11
- min: 2,
12
- max: 10,
21
+ min: POOL_MIN,
22
+ max: POOL_MAX,
13
23
  args: [
14
24
  ...shell_1.HEADLESS_POOL_ARGUMENT_SETS.basic,
15
25
  ...shell_1.HEADLESS_POOL_ARGUMENT_SETS.keychain_safe
@@ -30,8 +40,8 @@ function getHeadlessPool() {
30
40
  available: 0,
31
41
  borrowed: 0,
32
42
  pending: 0,
33
- max: 10,
34
- min: 2
43
+ max: POOL_MAX,
44
+ min: POOL_MIN
35
45
  };
36
46
  }
37
47
  //# sourceMappingURL=headless-pool-for-board.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"headless-pool-for-board.js","sourceRoot":"","sources":["../../server/controllers/headless-pool-for-board.ts"],"names":[],"mappings":";AAAA;;;GAGG;;AAqBH,0CAWC;AA9BD,iDAA4F;AAE5F,iCAAiC;AACjC,MAAM,SAAS,GAAG,IAAA,+BAAuB,EAAC,OAAO,EAAE;IACjD,GAAG,EAAE,CAAC;IACN,GAAG,EAAE,EAAE;IACP,IAAI,EAAE;QACJ,GAAG,mCAA2B,CAAC,KAAK;QACpC,GAAG,mCAA2B,CAAC,aAAa;KAC7C;IACD,oBAAoB,EAAE,KAAK;IAC3B,YAAY,EAAE,IAAI;IAClB,aAAa,EAAE,IAAI;CACpB,CAAC,CAAA;AAEF;;;GAGG;AACH,SAAgB,eAAe;IAC7B,OAAO;QACL,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,OAAO,EAAE;QAClC,OAAO,EAAE,CAAC,QAAa,EAAE,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,QAAQ,CAAC;QACvD,IAAI,EAAE,CAAC,EAAE,iDAAiD;QAC1D,SAAS,EAAE,CAAC;QACZ,QAAQ,EAAE,CAAC;QACX,OAAO,EAAE,CAAC;QACV,GAAG,EAAE,EAAE;QACP,GAAG,EAAE,CAAC;KACP,CAAA;AACH,CAAC","sourcesContent":["/**\n * Board Service Headless Pool\n * Using the unified headless pool system from @things-factory/shell\n */\n\nimport { getOrCreateHeadlessPool, HEADLESS_POOL_ARGUMENT_SETS } from '@things-factory/shell'\n\n// Create the board pool instance\nconst boardPool = getOrCreateHeadlessPool('board', {\n min: 2,\n max: 10,\n args: [\n ...HEADLESS_POOL_ARGUMENT_SETS.basic,\n ...HEADLESS_POOL_ARGUMENT_SETS.keychain_safe\n ],\n acquireTimeoutMillis: 15000,\n testOnBorrow: true,\n enableCleanup: true\n})\n\n/**\n * Get the board headless pool\n * @returns Pool instance with acquire/release methods\n */\nexport function getHeadlessPool() {\n return {\n acquire: () => boardPool.acquire(),\n release: (resource: any) => boardPool.release(resource),\n size: 0, // These will be dynamically calculated if needed\n available: 0,\n borrowed: 0,\n pending: 0,\n max: 10,\n min: 2\n }\n}"]}
1
+ {"version":3,"file":"headless-pool-for-board.js","sourceRoot":"","sources":["../../server/controllers/headless-pool-for-board.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;AAwBH,0CAWC;AAjCD,iDAA4F;AAE5F,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,uBAAuB,IAAI,GAAG,EAAE,EAAE,CAAC,CAAA;AACzE,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,uBAAuB,IAAI,IAAI,EAAE,EAAE,CAAC,CAAA;AAE1E,iCAAiC;AACjC,MAAM,SAAS,GAAG,IAAA,+BAAuB,EAAC,OAAO,EAAE;IACjD,GAAG,EAAE,QAAQ;IACb,GAAG,EAAE,QAAQ;IACb,IAAI,EAAE;QACJ,GAAG,mCAA2B,CAAC,KAAK;QACpC,GAAG,mCAA2B,CAAC,aAAa;KAC7C;IACD,oBAAoB,EAAE,KAAK;IAC3B,YAAY,EAAE,IAAI;IAClB,aAAa,EAAE,IAAI;CACpB,CAAC,CAAA;AAEF;;;GAGG;AACH,SAAgB,eAAe;IAC7B,OAAO;QACL,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,OAAO,EAAE;QAClC,OAAO,EAAE,CAAC,QAAa,EAAE,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,QAAQ,CAAC;QACvD,IAAI,EAAE,CAAC,EAAE,iDAAiD;QAC1D,SAAS,EAAE,CAAC;QACZ,QAAQ,EAAE,CAAC;QACX,OAAO,EAAE,CAAC;QACV,GAAG,EAAE,QAAQ;QACb,GAAG,EAAE,QAAQ;KACd,CAAA;AACH,CAAC","sourcesContent":["/**\n * Board Service Headless Pool\n * Using the unified headless pool system from @things-factory/shell\n *\n * 운영 환경 (GPU 없는 EC2 등) 에서는 idle 브라우저 상주 부담이 크다. min=0 으로 두면\n * 사용 안 할 때 메모리 0, 첫 요청 시에만 spawn (~1-2 초 cold start). thumbnail 은\n * fire-and-forget 백그라운드라 cold start 가 사용자 경험에 영향 없다.\n *\n * 환경 변수로 운영자가 조정:\n * HEADLESS_POOL_BOARD_MIN (default 0 — 메모리 절약)\n * HEADLESS_POOL_BOARD_MAX (default 10)\n */\n\nimport { getOrCreateHeadlessPool, HEADLESS_POOL_ARGUMENT_SETS } from '@things-factory/shell'\n\nconst POOL_MIN = parseInt(process.env.HEADLESS_POOL_BOARD_MIN ?? '0', 10)\nconst POOL_MAX = parseInt(process.env.HEADLESS_POOL_BOARD_MAX ?? '10', 10)\n\n// Create the board pool instance\nconst boardPool = getOrCreateHeadlessPool('board', {\n min: POOL_MIN,\n max: POOL_MAX,\n args: [\n ...HEADLESS_POOL_ARGUMENT_SETS.basic,\n ...HEADLESS_POOL_ARGUMENT_SETS.keychain_safe\n ],\n acquireTimeoutMillis: 15000,\n testOnBorrow: true,\n enableCleanup: true\n})\n\n/**\n * Get the board headless pool\n * @returns Pool instance with acquire/release methods\n */\nexport function getHeadlessPool() {\n return {\n acquire: () => boardPool.acquire(),\n release: (resource: any) => boardPool.release(resource),\n size: 0, // These will be dynamically calculated if needed\n available: 0,\n borrowed: 0,\n pending: 0,\n max: POOL_MAX,\n min: POOL_MIN\n }\n}"]}
@@ -60,10 +60,20 @@ async function setupLabelPage(browser) {
60
60
  await page.goto(url, { timeout: 0, waitUntil: 'load' });
61
61
  return { browser, page };
62
62
  }
63
+ /**
64
+ * 라벨 출력은 (자동 출고 워크플로우 등) 즉시성 요구 — cold start 비용 (browser+page
65
+ * setup ~2-3 초) 이 사용자 경험에 직결. min=1 로 최소 1 개 warm 유지가 기본.
66
+ * 사용 빈도가 매우 낮은 사이트는 환경 변수로 0 까지 낮출 수 있다.
67
+ *
68
+ * HEADLESS_POOL_LABEL_MIN (default 1)
69
+ * HEADLESS_POOL_LABEL_MAX (default 10)
70
+ */
71
+ const LABEL_POOL_MIN = parseInt(process.env.HEADLESS_POOL_LABEL_MIN ?? '1', 10);
72
+ const LABEL_POOL_MAX = parseInt(process.env.HEADLESS_POOL_LABEL_MAX ?? '10', 10);
63
73
  // Create the label pool instance with custom setup
64
74
  const labelPool = (0, shell_1.getOrCreateHeadlessPool)('label', {
65
- min: 2,
66
- max: 10,
75
+ min: LABEL_POOL_MIN,
76
+ max: LABEL_POOL_MAX,
67
77
  args: [
68
78
  ...shell_1.HEADLESS_POOL_ARGUMENT_SETS.basic,
69
79
  ...shell_1.HEADLESS_POOL_ARGUMENT_SETS.keychain_safe
@@ -93,8 +103,8 @@ function getHeadlessPool() {
93
103
  available: 0,
94
104
  borrowed: 0,
95
105
  pending: 0,
96
- max: 10,
97
- min: 2,
106
+ max: LABEL_POOL_MAX,
107
+ min: LABEL_POOL_MIN,
98
108
  clear: () => labelPool.reset() // For font change compatibility
99
109
  };
100
110
  }
@@ -1 +1 @@
1
- {"version":3,"file":"headless-pool-for-label.js","sourceRoot":"","sources":["../../server/controllers/headless-pool-for-label.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Label Service Headless Pool\n * Using the unified headless pool system from @things-factory/shell\n */\n\nimport { getOrCreateHeadlessPool, HEADLESS_POOL_ARGUMENT_SETS } from '@things-factory/shell'\nimport { pubsub } from '@things-factory/shell'\nimport { fonts } from './fonts.js'\n\n// Custom setup function for label page\nasync function setupLabelPage(browser: any) {\n const protocol = 'http'\n const host = 'localhost'\n const path = '/internal-label-command-view'\n const port = process.env.PORT\n const url = `${protocol}://${host}:${port}${path}`\n\n const page = await browser.newPage()\n const [fontsToUse, fontStyles] = await fonts()\n\n await page.setRequestInterception(true)\n\n page.on('console', async msg => {\n const args = await Promise.all(msg.args().map(arg => arg.jsonValue().catch(() => undefined)))\n\n if (args.some(a => a !== undefined)) {\n console.log(`[headless ${msg.type()}]`, ...args.filter(a => a !== undefined))\n } else {\n const text = msg.text()\n if (text) console.log(`[headless ${msg.type()}]`, text)\n }\n })\n\n page.on('pageerror', error => {\n console.log(`[headless pageerror] ${error.message}`)\n console.log(error.stack)\n })\n\n page.on('error', error => {\n console.log(`[headless fault] ${error}`)\n console.log(error.stack)\n })\n\n page.on('requestfailed', request => {\n console.log('Request failed:', request.url())\n })\n\n page.on('request', request => {\n if (request.url() === url) {\n request.continue({\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n postData: JSON.stringify({\n fonts: fontsToUse,\n fontStyles\n })\n })\n } else {\n request.continue()\n }\n })\n\n await page.goto(url, { timeout: 0, waitUntil: 'load' })\n\n return { browser, page }\n}\n\n// Create the label pool instance with custom setup\nconst labelPool = getOrCreateHeadlessPool('label', {\n min: 2,\n max: 10,\n args: [\n ...HEADLESS_POOL_ARGUMENT_SETS.basic,\n ...HEADLESS_POOL_ARGUMENT_SETS.keychain_safe\n ],\n acquireTimeoutMillis: 15000,\n testOnBorrow: true,\n enableCleanup: true,\n customSetup: setupLabelPage\n})\n\n// Font change event subscription\nsetTimeout(async () => {\n const eventSource = pubsub.subscribe('notify-font-changed')\n for await (const data of eventSource) {\n // Reset the pool when fonts change\n await labelPool.reset()\n }\n})\n\n/**\n * Get the label headless pool\n * @returns Pool instance with acquire/release methods\n */\nexport function getHeadlessPool() {\n return {\n acquire: () => labelPool.acquire(),\n release: (resource: any) => labelPool.release(resource),\n size: 0, // These will be dynamically calculated if needed\n available: 0,\n borrowed: 0,\n pending: 0,\n max: 10,\n min: 2,\n clear: () => labelPool.reset() // For font change compatibility\n }\n}"]}
1
+ {"version":3,"file":"headless-pool-for-label.js","sourceRoot":"","sources":["../../server/controllers/headless-pool-for-label.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Label Service Headless Pool\n * Using the unified headless pool system from @things-factory/shell\n */\n\nimport { getOrCreateHeadlessPool, HEADLESS_POOL_ARGUMENT_SETS } from '@things-factory/shell'\nimport { pubsub } from '@things-factory/shell'\nimport { fonts } from './fonts.js'\n\n// Custom setup function for label page\nasync function setupLabelPage(browser: any) {\n const protocol = 'http'\n const host = 'localhost'\n const path = '/internal-label-command-view'\n const port = process.env.PORT\n const url = `${protocol}://${host}:${port}${path}`\n\n const page = await browser.newPage()\n const [fontsToUse, fontStyles] = await fonts()\n\n await page.setRequestInterception(true)\n\n page.on('console', async msg => {\n const args = await Promise.all(msg.args().map(arg => arg.jsonValue().catch(() => undefined)))\n\n if (args.some(a => a !== undefined)) {\n console.log(`[headless ${msg.type()}]`, ...args.filter(a => a !== undefined))\n } else {\n const text = msg.text()\n if (text) console.log(`[headless ${msg.type()}]`, text)\n }\n })\n\n page.on('pageerror', error => {\n console.log(`[headless pageerror] ${error.message}`)\n console.log(error.stack)\n })\n\n page.on('error', error => {\n console.log(`[headless fault] ${error}`)\n console.log(error.stack)\n })\n\n page.on('requestfailed', request => {\n console.log('Request failed:', request.url())\n })\n\n page.on('request', request => {\n if (request.url() === url) {\n request.continue({\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n postData: JSON.stringify({\n fonts: fontsToUse,\n fontStyles\n })\n })\n } else {\n request.continue()\n }\n })\n\n await page.goto(url, { timeout: 0, waitUntil: 'load' })\n\n return { browser, page }\n}\n\n/**\n * 라벨 출력은 (자동 출고 워크플로우 등) 즉시성 요구 — cold start 비용 (browser+page\n * setup ~2-3 초) 이 사용자 경험에 직결. min=1 로 최소 1 개 warm 유지가 기본.\n * 사용 빈도가 매우 낮은 사이트는 환경 변수로 0 까지 낮출 수 있다.\n *\n * HEADLESS_POOL_LABEL_MIN (default 1)\n * HEADLESS_POOL_LABEL_MAX (default 10)\n */\nconst LABEL_POOL_MIN = parseInt(process.env.HEADLESS_POOL_LABEL_MIN ?? '1', 10)\nconst LABEL_POOL_MAX = parseInt(process.env.HEADLESS_POOL_LABEL_MAX ?? '10', 10)\n\n// Create the label pool instance with custom setup\nconst labelPool = getOrCreateHeadlessPool('label', {\n min: LABEL_POOL_MIN,\n max: LABEL_POOL_MAX,\n args: [\n ...HEADLESS_POOL_ARGUMENT_SETS.basic,\n ...HEADLESS_POOL_ARGUMENT_SETS.keychain_safe\n ],\n acquireTimeoutMillis: 15000,\n testOnBorrow: true,\n enableCleanup: true,\n customSetup: setupLabelPage\n})\n\n// Font change event subscription\nsetTimeout(async () => {\n const eventSource = pubsub.subscribe('notify-font-changed')\n for await (const data of eventSource) {\n // Reset the pool when fonts change\n await labelPool.reset()\n }\n})\n\n/**\n * Get the label headless pool\n * @returns Pool instance with acquire/release methods\n */\nexport function getHeadlessPool() {\n return {\n acquire: () => labelPool.acquire(),\n release: (resource: any) => labelPool.release(resource),\n size: 0, // These will be dynamically calculated if needed\n available: 0,\n borrowed: 0,\n pending: 0,\n max: LABEL_POOL_MAX,\n min: LABEL_POOL_MIN,\n clear: () => labelPool.reset() // For font change compatibility\n }\n}"]}
@@ -1,14 +1,5 @@
1
1
  export declare const BoardFunc: {
2
- boardToZpl: ({ id, model, data, orientation, mirror, upsideDown, context, draft }: {
3
- id: any;
4
- model: any;
5
- data: any;
6
- orientation: any;
7
- mirror?: boolean;
8
- upsideDown?: boolean;
9
- context: any;
10
- draft?: boolean;
11
- }) => Promise<string>;
2
+ boardToZpl: (opts: import("./label-command.js").LabelCommandOptions) => Promise<string | undefined>;
12
3
  boardToPdf: ({ id, model, data, width, height, options, context }?: {
13
4
  id?: string;
14
5
  model?: any;
@@ -1,5 +1,25 @@
1
+ export interface LabelCommandOptions {
2
+ id?: string;
3
+ model?: any;
4
+ data?: any;
5
+ orientation?: string;
6
+ mirror?: boolean;
7
+ upsideDown?: boolean;
8
+ context: any;
9
+ draft?: boolean;
10
+ }
11
+ export declare function fnv1a(s: string): string;
12
+ export declare function cacheKey(opts: LabelCommandOptions): string;
13
+ /** 진단 / 운영용 — 현재 cache hit ratio 등 모니터링 가능. */
14
+ export declare function getLabelCacheStats(): {
15
+ cacheSize: number;
16
+ inFlight: number;
17
+ };
18
+ /** Test-only — 캐시 / in-flight state 초기화. */
19
+ export declare function _resetLabelState(): void;
20
+ export declare function _setBuildOverride(fn: ((opts: LabelCommandOptions) => Promise<string | undefined>) | null): void;
1
21
  /**
2
- * 라벨 출력
22
+ * 라벨 출력 — ZPL/GRF 문자열 반환.
3
23
  *
4
24
  * @param {String} id 모델 ID
5
25
  * @param {Object} data 매핑할 데이터
@@ -7,13 +27,4 @@
7
27
  * @param {boolean} mirror 좌우반전
8
28
  * @param {boolean} upsideDown 상하반전
9
29
  */
10
- export declare const labelcommand: ({ id, model, data, orientation, mirror, upsideDown, context, draft }: {
11
- id: any;
12
- model: any;
13
- data: any;
14
- orientation: any;
15
- mirror?: boolean;
16
- upsideDown?: boolean;
17
- context: any;
18
- draft?: boolean;
19
- }) => Promise<string>;
30
+ export declare const labelcommand: (opts: LabelCommandOptions) => Promise<string | undefined>;
@@ -1,29 +1,96 @@
1
1
  "use strict";
2
2
  /*
3
3
  * Copyright © HatioLab Inc. All rights reserved.
4
+ *
5
+ * Label command (ZPL/GRF) — 백엔드 헤드리스 *전용*. 클라이언트 캡처는 anti-aliasing
6
+ * / sub-pixel / DPR 차이로 ZPL 1-bit 정확 비트맵과 호환 안 됨. 또한 자동 출고
7
+ * 워크플로우는 클라이언트 없이 트리거되므로 헤드리스 외 옵션 없음.
8
+ *
9
+ * 비용을 줄이는 두 메커니즘:
10
+ * 1) In-flight dedup: 동일 (template, data, orientation, mirror, upsideDown)
11
+ * 요청이 진행 중이면 같은 Promise 를 공유 — 라벨 출력 큐에서 같은 라벨이
12
+ * 동시 N 회 요청되어도 puppeteer evaluate 는 1 회.
13
+ * 2) LRU GRF 캐시: 같은 입력에 대해 TTL 동안 GRF 재사용 — 대량 인쇄에서
14
+ * 같은 라벨 연속 출력 시 puppeteer 호출 0 회.
4
15
  */
5
16
  Object.defineProperty(exports, "__esModule", { value: true });
6
17
  exports.labelcommand = void 0;
18
+ exports.fnv1a = fnv1a;
19
+ exports.cacheKey = cacheKey;
20
+ exports.getLabelCacheStats = getLabelCacheStats;
21
+ exports._resetLabelState = _resetLabelState;
22
+ exports._setBuildOverride = _setBuildOverride;
7
23
  const headless_pool_for_label_js_1 = require("./headless-pool-for-label.js");
8
24
  const headless_model_js_1 = require("./headless-model.js");
25
+ // ── 캐시 / dedup state ─────────────────────────────────────────────────────
26
+ const GRF_CACHE_MAX = 200; // 최근 200 개 라벨 유지
27
+ const GRF_CACHE_TTL_MS = 60 * 1000; // 60 초 — id-기반 board update 도 1 분 내 반영
28
+ const grfCache = new Map();
29
+ const inFlight = new Map();
30
+ function fnv1a(s) {
31
+ let hash = 2166136261;
32
+ for (let i = 0; i < s.length; i++) {
33
+ hash ^= s.charCodeAt(i);
34
+ hash = Math.imul(hash, 16777619) >>> 0;
35
+ }
36
+ return hash.toString(16);
37
+ }
38
+ function cacheKey(opts) {
39
+ // id 기반은 board update 가 있을 수 있으므로 TTL 짧게.
40
+ // model 직접 전달은 model 자체 hash — 변경 시 자동으로 새 key.
41
+ const tpl = opts.id
42
+ ? `id:${opts.id}:${opts.draft ? 'd' : 'r'}`
43
+ : `m:${fnv1a(typeof opts.model === 'string' ? opts.model : JSON.stringify(opts.model ?? null))}`;
44
+ const dataKey = opts.data ? fnv1a(JSON.stringify(opts.data)) : '_';
45
+ return `${tpl}|${dataKey}|${opts.orientation ?? ''}|${opts.mirror ? 1 : 0}|${opts.upsideDown ? 1 : 0}`;
46
+ }
47
+ function cacheGet(key) {
48
+ const entry = grfCache.get(key);
49
+ if (!entry)
50
+ return undefined;
51
+ if (entry.expiresAt < Date.now()) {
52
+ grfCache.delete(key);
53
+ return undefined;
54
+ }
55
+ // LRU refresh — 최근 사용을 Map 마지막으로 이동.
56
+ grfCache.delete(key);
57
+ grfCache.set(key, entry);
58
+ return entry.value;
59
+ }
60
+ function cacheSet(key, value) {
61
+ grfCache.delete(key);
62
+ grfCache.set(key, { value, expiresAt: Date.now() + GRF_CACHE_TTL_MS });
63
+ if (grfCache.size > GRF_CACHE_MAX) {
64
+ const oldest = grfCache.keys().next().value;
65
+ if (oldest !== undefined)
66
+ grfCache.delete(oldest);
67
+ }
68
+ }
69
+ /** 진단 / 운영용 — 현재 cache hit ratio 등 모니터링 가능. */
70
+ function getLabelCacheStats() {
71
+ return { cacheSize: grfCache.size, inFlight: inFlight.size };
72
+ }
73
+ /** Test-only — 캐시 / in-flight state 초기화. */
74
+ function _resetLabelState() {
75
+ grfCache.clear();
76
+ inFlight.clear();
77
+ }
9
78
  /**
10
- * 라벨 출력
11
- *
12
- * @param {String} id 모델 ID
13
- * @param {Object} data 매핑할 데이터
14
- * @param {String} orientation (시계방향) N: 0, R: 90, I: 180, B: 270
15
- * @param {boolean} mirror 좌우반전
16
- * @param {boolean} upsideDown 상하반전
79
+ * Test-only — buildLabelGrf 를 mock 으로 교체. production 에서는 항상 default 사용.
17
80
  */
18
- const labelcommand = async ({ id, model, data, orientation, mirror = false, upsideDown = false, context, draft = false }) => {
19
- const { domain } = context.state;
81
+ let _buildOverride = null;
82
+ function _setBuildOverride(fn) {
83
+ _buildOverride = fn;
84
+ }
85
+ // ── 헤드리스 evaluate (실제 puppeteer 호출). ──────────────────────────────────
86
+ async function buildLabelGrf(opts) {
87
+ const { domain } = opts.context.state;
20
88
  const browser = (await (0, headless_pool_for_label_js_1.getHeadlessPool)().acquire());
21
- if (!browser) {
22
- return;
23
- }
89
+ if (!browser)
90
+ return undefined;
24
91
  try {
25
92
  const { page } = browser;
26
- var { model } = await (0, headless_model_js_1.headlessModel)({ domain, model, id }, draft);
93
+ const { model: resolvedModel } = await (0, headless_model_js_1.headlessModel)({ domain, model: opts.model, id: opts.id }, !!opts.draft);
27
94
  const grf = await page.evaluate(async (model, data, orientation, mirror, upsideDown) => {
28
95
  //@ts-ignore
29
96
  let s = createScene(model);
@@ -41,7 +108,7 @@ const labelcommand = async ({ id, model, data, orientation, mirror = false, upsi
41
108
  s.dispose();
42
109
  });
43
110
  });
44
- }, model, data, orientation, mirror, upsideDown);
111
+ }, resolvedModel, opts.data, opts.orientation, opts.mirror, opts.upsideDown);
45
112
  return `
46
113
  ^XA
47
114
  ^GFA,${grf}
@@ -52,6 +119,38 @@ const labelcommand = async ({ id, model, data, orientation, mirror = false, upsi
52
119
  // 에러 발생 시에도 반드시 풀에 브라우저를 반환
53
120
  (0, headless_pool_for_label_js_1.getHeadlessPool)().release(browser);
54
121
  }
122
+ }
123
+ /**
124
+ * 라벨 출력 — ZPL/GRF 문자열 반환.
125
+ *
126
+ * @param {String} id 모델 ID
127
+ * @param {Object} data 매핑할 데이터
128
+ * @param {String} orientation (시계방향) N: 0, R: 90, I: 180, B: 270
129
+ * @param {boolean} mirror 좌우반전
130
+ * @param {boolean} upsideDown 상하반전
131
+ */
132
+ const labelcommand = async (opts) => {
133
+ const key = cacheKey(opts);
134
+ // 1) GRF LRU 캐시 — 같은 입력 반복 출력 시 puppeteer 호출 0.
135
+ const cached = cacheGet(key);
136
+ if (cached !== undefined)
137
+ return cached;
138
+ // 2) In-flight dedup — 동일 요청이 이미 진행 중이면 같은 Promise share.
139
+ const existing = inFlight.get(key);
140
+ if (existing)
141
+ return existing;
142
+ const builder = _buildOverride ?? buildLabelGrf;
143
+ const promise = builder(opts)
144
+ .then(result => {
145
+ if (result)
146
+ cacheSet(key, result);
147
+ return result;
148
+ })
149
+ .finally(() => {
150
+ inFlight.delete(key);
151
+ });
152
+ inFlight.set(key, promise);
153
+ return promise;
55
154
  };
56
155
  exports.labelcommand = labelcommand;
57
156
  //# sourceMappingURL=label-command.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"label-command.js","sourceRoot":"","sources":["../../server/controllers/label-command.ts"],"names":[],"mappings":";AAAA;;GAEG;;;AAEH,6EAA8D;AAC9D,2DAAmD;AAEnD;;;;;;;;GAQG;AACI,MAAM,YAAY,GAAG,KAAK,EAAE,EACjC,EAAE,EACF,KAAK,EACL,IAAI,EACJ,WAAW,EACX,MAAM,GAAG,KAAK,EACd,UAAU,GAAG,KAAK,EAClB,OAAO,EACP,KAAK,GAAG,KAAK,EACd,EAAE,EAAE;IACH,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;IAEhC,MAAM,OAAO,GAAG,CAAC,MAAM,IAAA,4CAAe,GAAE,CAAC,OAAO,EAAE,CAAQ,CAAA;IAC1D,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAM;IACR,CAAC;IAED,IAAI,CAAC;QACH,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAA;QAExB,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,IAAA,iCAAa,EAAC,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,CAAA;QAEjE,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,QAAQ,CAC7B,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE;YACrD,YAAY;YACZ,IAAI,CAAC,GAAG,WAAW,CAAC,KAAK,CAAC,CAAA;YAC1B,IAAI,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,IAAI,GAAG,IAAI,CAAA;YACf,CAAC;YACD,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE;gBAC3B,aAAa;gBACb,qBAAqB,CAAC,GAAG,EAAE;oBACzB,aAAa;oBACb,IAAI,GAAG,GAAG,cAAc,CAAC,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,CAAC,CAAA;oBACnE,OAAO,CAAC,GAAG,CAAC,CAAA;oBACZ,aAAa;oBACb,cAAc,CAAC,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,CAAA;oBACpC,CAAC,CAAC,OAAO,EAAE,CAAA;gBACb,CAAC,CAAC,CAAA;YACJ,CAAC,CAAC,CAAA;QACJ,CAAC,EACD,KAAK,EACL,IAAI,EACJ,WAAW,EACX,MAAM,EACN,UAAU,CACX,CAAA;QAED,OAAO;;OAEJ,GAAG;;IAEN,CAAA;IACF,CAAC;YAAS,CAAC;QACT,4BAA4B;QAC5B,IAAA,4CAAe,GAAE,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;IACpC,CAAC;AACH,CAAC,CAAA;AAzDY,QAAA,YAAY,gBAyDxB","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\n\nimport { getHeadlessPool } from './headless-pool-for-label.js'\nimport { headlessModel } from './headless-model.js'\n\n/**\n * 라벨 출력\n *\n * @param {String} id 모델 ID\n * @param {Object} data 매핑할 데이터\n * @param {String} orientation (시계방향) N: 0, R: 90, I: 180, B: 270\n * @param {boolean} mirror 좌우반전\n * @param {boolean} upsideDown 상하반전\n */\nexport const labelcommand = async ({\n id,\n model,\n data,\n orientation,\n mirror = false,\n upsideDown = false,\n context,\n draft = false\n}) => {\n const { domain } = context.state\n\n const browser = (await getHeadlessPool().acquire()) as any\n if (!browser) {\n return\n }\n\n try {\n const { page } = browser\n\n var { model } = await headlessModel({ domain, model, id }, draft)\n\n const grf = await page.evaluate(\n async (model, data, orientation, mirror, upsideDown) => {\n //@ts-ignore\n let s = createScene(model)\n if (data) {\n s.data = data\n }\n return new Promise(resolve => {\n // @ts-ignore\n requestAnimationFrame(() => {\n // @ts-ignore\n let grf = imageDataToGrf(s, model, orientation, mirror, upsideDown)\n resolve(grf)\n // @ts-ignore\n sceneContainer.removeChild(s.target)\n s.dispose()\n })\n })\n },\n model,\n data,\n orientation,\n mirror,\n upsideDown\n )\n\n return `\n^XA\n^GFA,${grf}\n^FS\n^XZ`\n } finally {\n // 에러 발생 시에도 반드시 풀에 브라우저를 반환\n getHeadlessPool().release(browser)\n }\n}\n"]}
1
+ {"version":3,"file":"label-command.js","sourceRoot":"","sources":["../../server/controllers/label-command.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n *\n * Label command (ZPL/GRF) — 백엔드 헤드리스 *전용*. 클라이언트 캡처는 anti-aliasing\n * / sub-pixel / DPR 차이로 ZPL 1-bit 정확 비트맵과 호환 안 됨. 또한 자동 출고\n * 워크플로우는 클라이언트 없이 트리거되므로 헤드리스 외 옵션 없음.\n *\n * 비용을 줄이는 두 메커니즘:\n * 1) In-flight dedup: 동일 (template, data, orientation, mirror, upsideDown)\n * 요청이 진행 중이면 같은 Promise 를 공유 — 라벨 출력 큐에서 같은 라벨이\n * 동시 N 회 요청되어도 puppeteer evaluate 는 1 회.\n * 2) LRU GRF 캐시: 같은 입력에 대해 TTL 동안 GRF 재사용 — 대량 인쇄에서\n * 같은 라벨 연속 출력 시 puppeteer 호출 0 회.\n */\n\nimport { getHeadlessPool } from './headless-pool-for-label.js'\nimport { headlessModel } from './headless-model.js'\n\nexport interface LabelCommandOptions {\n id?: string\n model?: any\n data?: any\n orientation?: string\n mirror?: boolean\n upsideDown?: boolean\n context: any\n draft?: boolean\n}\n\n// ── 캐시 / dedup state ─────────────────────────────────────────────────────\n\nconst GRF_CACHE_MAX = 200 // 최근 200 개 라벨 유지\nconst GRF_CACHE_TTL_MS = 60 * 1000 // 60 초 — id-기반 board update 도 1 분 내 반영\n\ninterface CacheEntry { value: string; expiresAt: number }\nconst grfCache = new Map<string, CacheEntry>()\nconst inFlight = new Map<string, Promise<string | undefined>>()\n\nexport function fnv1a(s: string): string {\n let hash = 2166136261\n for (let i = 0; i < s.length; i++) {\n hash ^= s.charCodeAt(i)\n hash = Math.imul(hash, 16777619) >>> 0\n }\n return hash.toString(16)\n}\n\nexport function cacheKey(opts: LabelCommandOptions): string {\n // id 기반은 board update 가 있을 수 있으므로 TTL 짧게.\n // model 직접 전달은 model 자체 hash — 변경 시 자동으로 새 key.\n const tpl = opts.id\n ? `id:${opts.id}:${opts.draft ? 'd' : 'r'}`\n : `m:${fnv1a(typeof opts.model === 'string' ? opts.model : JSON.stringify(opts.model ?? null))}`\n const dataKey = opts.data ? fnv1a(JSON.stringify(opts.data)) : '_'\n return `${tpl}|${dataKey}|${opts.orientation ?? ''}|${opts.mirror ? 1 : 0}|${opts.upsideDown ? 1 : 0}`\n}\n\nfunction cacheGet(key: string): string | undefined {\n const entry = grfCache.get(key)\n if (!entry) return undefined\n if (entry.expiresAt < Date.now()) {\n grfCache.delete(key)\n return undefined\n }\n // LRU refresh — 최근 사용을 Map 마지막으로 이동.\n grfCache.delete(key)\n grfCache.set(key, entry)\n return entry.value\n}\n\nfunction cacheSet(key: string, value: string): void {\n grfCache.delete(key)\n grfCache.set(key, { value, expiresAt: Date.now() + GRF_CACHE_TTL_MS })\n if (grfCache.size > GRF_CACHE_MAX) {\n const oldest = grfCache.keys().next().value\n if (oldest !== undefined) grfCache.delete(oldest)\n }\n}\n\n/** 진단 / 운영용 — 현재 cache hit ratio 등 모니터링 가능. */\nexport function getLabelCacheStats() {\n return { cacheSize: grfCache.size, inFlight: inFlight.size }\n}\n\n/** Test-only — 캐시 / in-flight state 초기화. */\nexport function _resetLabelState(): void {\n grfCache.clear()\n inFlight.clear()\n}\n\n/**\n * Test-only — buildLabelGrf 를 mock 으로 교체. production 에서는 항상 default 사용.\n */\nlet _buildOverride: ((opts: LabelCommandOptions) => Promise<string | undefined>) | null = null\nexport function _setBuildOverride(fn: ((opts: LabelCommandOptions) => Promise<string | undefined>) | null): void {\n _buildOverride = fn\n}\n\n// ── 헤드리스 evaluate (실제 puppeteer 호출). ──────────────────────────────────\n\nasync function buildLabelGrf(opts: LabelCommandOptions): Promise<string | undefined> {\n const { domain } = opts.context.state\n\n const browser = (await getHeadlessPool().acquire()) as any\n if (!browser) return undefined\n\n try {\n const { page } = browser\n\n const { model: resolvedModel } = await headlessModel(\n { domain, model: opts.model, id: opts.id },\n !!opts.draft\n )\n\n const grf = await page.evaluate(\n async (model, data, orientation, mirror, upsideDown) => {\n //@ts-ignore\n let s = createScene(model)\n if (data) {\n s.data = data\n }\n return new Promise(resolve => {\n // @ts-ignore\n requestAnimationFrame(() => {\n // @ts-ignore\n let grf = imageDataToGrf(s, model, orientation, mirror, upsideDown)\n resolve(grf)\n // @ts-ignore\n sceneContainer.removeChild(s.target)\n s.dispose()\n })\n })\n },\n resolvedModel,\n opts.data,\n opts.orientation,\n opts.mirror,\n opts.upsideDown\n )\n\n return `\n^XA\n^GFA,${grf}\n^FS\n^XZ`\n } finally {\n // 에러 발생 시에도 반드시 풀에 브라우저를 반환\n getHeadlessPool().release(browser)\n }\n}\n\n/**\n * 라벨 출력 — ZPL/GRF 문자열 반환.\n *\n * @param {String} id 모델 ID\n * @param {Object} data 매핑할 데이터\n * @param {String} orientation (시계방향) N: 0, R: 90, I: 180, B: 270\n * @param {boolean} mirror 좌우반전\n * @param {boolean} upsideDown 상하반전\n */\nexport const labelcommand = async (opts: LabelCommandOptions): Promise<string | undefined> => {\n const key = cacheKey(opts)\n\n // 1) GRF LRU 캐시 — 같은 입력 반복 출력 시 puppeteer 호출 0.\n const cached = cacheGet(key)\n if (cached !== undefined) return cached\n\n // 2) In-flight dedup — 동일 요청이 이미 진행 중이면 같은 Promise share.\n const existing = inFlight.get(key)\n if (existing) return existing\n\n const builder = _buildOverride ?? buildLabelGrf\n const promise = builder(opts)\n .then(result => {\n if (result) cacheSet(key, result)\n return result\n })\n .finally(() => {\n inFlight.delete(key)\n })\n\n inFlight.set(key, promise)\n return promise\n}\n"]}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Label Command — in-flight dedup + LRU GRF cache 단위 테스트.
3
+ *
4
+ * puppeteer 의존성은 _setBuildOverride 로 mock. 검증 대상:
5
+ * 1) cacheKey — 같은 입력 같은 키 / 다른 입력 다른 키 / 정규화
6
+ * 2) cache hit — 같은 (template, data, ...) 반복 호출 시 puppeteer 0 회
7
+ * 3) in-flight dedup — 동일 요청 동시 진행 시 build 함수 1 회만, 모든 caller 가 같은 결과
8
+ * 4) cache TTL — 만료된 entry 는 새로 build
9
+ * 5) LRU eviction — 캐시 가득 차면 오래된 것부터 제거
10
+ * 6) build 실패 시 캐시 안 됨 → 다음 호출에서 재시도
11
+ * 7) id 와 model 별 다른 키 공간
12
+ */
13
+ export {};
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ /**
3
+ * Label Command — in-flight dedup + LRU GRF cache 단위 테스트.
4
+ *
5
+ * puppeteer 의존성은 _setBuildOverride 로 mock. 검증 대상:
6
+ * 1) cacheKey — 같은 입력 같은 키 / 다른 입력 다른 키 / 정규화
7
+ * 2) cache hit — 같은 (template, data, ...) 반복 호출 시 puppeteer 0 회
8
+ * 3) in-flight dedup — 동일 요청 동시 진행 시 build 함수 1 회만, 모든 caller 가 같은 결과
9
+ * 4) cache TTL — 만료된 entry 는 새로 build
10
+ * 5) LRU eviction — 캐시 가득 차면 오래된 것부터 제거
11
+ * 6) build 실패 시 캐시 안 됨 → 다음 호출에서 재시도
12
+ * 7) id 와 model 별 다른 키 공간
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ // import 시점에 끌려오는 무거운 deps (headless pool, headless model) 은 어차피
16
+ // _setBuildOverride 로 교체하므로 module load 만 가능하게 mock.
17
+ jest.mock('./headless-pool-for-label', () => ({
18
+ getHeadlessPool: () => ({ acquire: jest.fn(), release: jest.fn() })
19
+ }));
20
+ jest.mock('./headless-model', () => ({
21
+ headlessModel: async () => ({ model: {} })
22
+ }));
23
+ const label_command_1 = require("./label-command");
24
+ const ctx = { state: { domain: { subdomain: 'test' }, user: { id: 'u1' } } };
25
+ // ─── fnv1a ──────────────────────────────────────────────────────────────────
26
+ describe('label fnv1a hash', () => {
27
+ test('결정성', () => {
28
+ expect((0, label_command_1.fnv1a)('foo')).toBe((0, label_command_1.fnv1a)('foo'));
29
+ });
30
+ test('충돌 적음 — 50 개 입력', () => {
31
+ const hashes = new Set();
32
+ for (let i = 0; i < 50; i++) {
33
+ hashes.add((0, label_command_1.fnv1a)(`label-${i}-{"data":${i}}`));
34
+ }
35
+ expect(hashes.size).toBe(50);
36
+ });
37
+ });
38
+ // ─── cacheKey ───────────────────────────────────────────────────────────────
39
+ describe('cacheKey 생성', () => {
40
+ test('같은 입력 → 같은 key', () => {
41
+ const opts = { id: 'lbl1', data: { x: 1 }, orientation: 'N', context: ctx };
42
+ expect((0, label_command_1.cacheKey)(opts)).toBe((0, label_command_1.cacheKey)(opts));
43
+ });
44
+ test('id 다르면 다른 key', () => {
45
+ const a = (0, label_command_1.cacheKey)({ id: 'lbl1', data: { x: 1 }, context: ctx });
46
+ const b = (0, label_command_1.cacheKey)({ id: 'lbl2', data: { x: 1 }, context: ctx });
47
+ expect(a).not.toBe(b);
48
+ });
49
+ test('draft / released 분리', () => {
50
+ const a = (0, label_command_1.cacheKey)({ id: 'lbl1', data: {}, draft: false, context: ctx });
51
+ const b = (0, label_command_1.cacheKey)({ id: 'lbl1', data: {}, draft: true, context: ctx });
52
+ expect(a).not.toBe(b);
53
+ });
54
+ test('orientation 다르면 다른 key', () => {
55
+ const a = (0, label_command_1.cacheKey)({ id: 'lbl1', data: {}, orientation: 'N', context: ctx });
56
+ const b = (0, label_command_1.cacheKey)({ id: 'lbl1', data: {}, orientation: 'R', context: ctx });
57
+ expect(a).not.toBe(b);
58
+ });
59
+ test('mirror / upsideDown flag 가 key 에 반영', () => {
60
+ const base = { id: 'lbl1', data: {}, context: ctx };
61
+ const a = (0, label_command_1.cacheKey)(base);
62
+ const b = (0, label_command_1.cacheKey)({ ...base, mirror: true });
63
+ const c = (0, label_command_1.cacheKey)({ ...base, upsideDown: true });
64
+ const d = (0, label_command_1.cacheKey)({ ...base, mirror: true, upsideDown: true });
65
+ expect(new Set([a, b, c, d]).size).toBe(4);
66
+ });
67
+ test('data 변경 시 다른 key', () => {
68
+ const a = (0, label_command_1.cacheKey)({ id: 'lbl1', data: { sn: 'A1' }, context: ctx });
69
+ const b = (0, label_command_1.cacheKey)({ id: 'lbl1', data: { sn: 'A2' }, context: ctx });
70
+ expect(a).not.toBe(b);
71
+ });
72
+ test('data 가 deep 동일하면 같은 key (key 순서 무관)', () => {
73
+ const a = (0, label_command_1.cacheKey)({ id: 'lbl1', data: { x: 1, y: 2 }, context: ctx });
74
+ const b = (0, label_command_1.cacheKey)({ id: 'lbl1', data: { x: 1, y: 2 }, context: ctx });
75
+ expect(a).toBe(b);
76
+ });
77
+ test('id 와 model 직접 전달은 다른 key 공간', () => {
78
+ const a = (0, label_command_1.cacheKey)({ id: 'lbl1', data: {}, context: ctx });
79
+ const b = (0, label_command_1.cacheKey)({ model: { template: 'abc' }, data: {}, context: ctx });
80
+ expect(a).not.toBe(b);
81
+ });
82
+ test('model JSON 내용이 같으면 같은 key (id 없을 때)', () => {
83
+ const a = (0, label_command_1.cacheKey)({ model: { width: 100 }, data: {}, context: ctx });
84
+ const b = (0, label_command_1.cacheKey)({ model: { width: 100 }, data: {}, context: ctx });
85
+ expect(a).toBe(b);
86
+ });
87
+ });
88
+ // ─── labelcommand: cache + dedup ────────────────────────────────────────────
89
+ describe('labelcommand — cache + in-flight dedup', () => {
90
+ let buildCalls;
91
+ let nextResult;
92
+ let buildShouldFail;
93
+ let buildDelay;
94
+ beforeEach(() => {
95
+ buildCalls = [];
96
+ nextResult = '^XA\n^GFA,abc\n^FS\n^XZ';
97
+ buildShouldFail = false;
98
+ buildDelay = 0;
99
+ (0, label_command_1._setBuildOverride)(async (opts) => {
100
+ buildCalls.push(opts);
101
+ if (buildDelay > 0) {
102
+ await new Promise(r => setTimeout(r, buildDelay));
103
+ }
104
+ if (buildShouldFail)
105
+ throw new Error('build failed');
106
+ return nextResult;
107
+ });
108
+ (0, label_command_1._resetLabelState)();
109
+ });
110
+ afterEach(() => {
111
+ (0, label_command_1._setBuildOverride)(null);
112
+ (0, label_command_1._resetLabelState)();
113
+ });
114
+ test('첫 호출 → build 1 회, 결과 반환', async () => {
115
+ const result = await (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, context: ctx });
116
+ expect(buildCalls).toHaveLength(1);
117
+ expect(result).toBe('^XA\n^GFA,abc\n^FS\n^XZ');
118
+ });
119
+ test('동일 요청 두 번 sequential → 두 번째는 cache hit', async () => {
120
+ await (0, label_command_1.labelcommand)({ id: 'lbl1', data: { sn: 'A1' }, context: ctx });
121
+ expect(buildCalls).toHaveLength(1);
122
+ const second = await (0, label_command_1.labelcommand)({ id: 'lbl1', data: { sn: 'A1' }, context: ctx });
123
+ expect(buildCalls).toHaveLength(1); // cache hit
124
+ expect(second).toBe('^XA\n^GFA,abc\n^FS\n^XZ');
125
+ });
126
+ test('동일 요청 동시 100 회 → in-flight dedup 으로 build 1 회', async () => {
127
+ buildDelay = 50;
128
+ const promises = Array.from({ length: 100 }, () => (0, label_command_1.labelcommand)({ id: 'lbl1', data: { sn: 'A1' }, context: ctx }));
129
+ const results = await Promise.all(promises);
130
+ expect(buildCalls).toHaveLength(1);
131
+ // 모든 caller 가 같은 결과
132
+ expect(new Set(results).size).toBe(1);
133
+ });
134
+ test('다른 요청은 별도 build', async () => {
135
+ await (0, label_command_1.labelcommand)({ id: 'lbl1', data: { sn: 'A1' }, context: ctx });
136
+ await (0, label_command_1.labelcommand)({ id: 'lbl1', data: { sn: 'A2' }, context: ctx });
137
+ await (0, label_command_1.labelcommand)({ id: 'lbl2', data: { sn: 'A1' }, context: ctx });
138
+ expect(buildCalls).toHaveLength(3);
139
+ });
140
+ test('build 실패 시 cache 안 됨 → 다음 호출에서 재시도', async () => {
141
+ buildShouldFail = true;
142
+ await expect((0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, context: ctx })).rejects.toThrow('build failed');
143
+ expect(buildCalls).toHaveLength(1);
144
+ buildShouldFail = false;
145
+ const result = await (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, context: ctx });
146
+ expect(buildCalls).toHaveLength(2);
147
+ expect(result).toBe('^XA\n^GFA,abc\n^FS\n^XZ');
148
+ });
149
+ test('build undefined 반환 시 cache 안 됨', async () => {
150
+ nextResult = undefined;
151
+ const r1 = await (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, context: ctx });
152
+ expect(r1).toBeUndefined();
153
+ expect(buildCalls).toHaveLength(1);
154
+ // 같은 입력 다시 — cache 없으므로 또 build
155
+ const r2 = await (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, context: ctx });
156
+ expect(buildCalls).toHaveLength(2);
157
+ });
158
+ test('orientation 다르면 별도 cache', async () => {
159
+ await (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, orientation: 'N', context: ctx });
160
+ await (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, orientation: 'R', context: ctx });
161
+ await (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, orientation: 'N', context: ctx });
162
+ expect(buildCalls).toHaveLength(2); // N, R 각각 1 회. N 의 두 번째는 cache hit
163
+ });
164
+ test('대량 인쇄 시나리오 — 같은 라벨 200 장 출력 시 build 1 회', async () => {
165
+ const params = { id: 'lbl-mass', data: { common: 'data' }, orientation: 'N', context: ctx };
166
+ for (let i = 0; i < 200; i++) {
167
+ await (0, label_command_1.labelcommand)(params);
168
+ }
169
+ expect(buildCalls).toHaveLength(1);
170
+ });
171
+ test('각 라벨이 다른 시리얼 번호인 시나리오 — 매번 build', async () => {
172
+ for (let i = 0; i < 10; i++) {
173
+ await (0, label_command_1.labelcommand)({
174
+ id: 'lbl-serial',
175
+ data: { sn: `SN-${i}` },
176
+ context: ctx
177
+ });
178
+ }
179
+ expect(buildCalls).toHaveLength(10);
180
+ });
181
+ test('cache stats 가 합리적 값 반환', async () => {
182
+ const before = (0, label_command_1.getLabelCacheStats)();
183
+ expect(before.cacheSize).toBe(0);
184
+ await (0, label_command_1.labelcommand)({ id: 'lbl1', data: { x: 1 }, context: ctx });
185
+ await (0, label_command_1.labelcommand)({ id: 'lbl2', data: { x: 1 }, context: ctx });
186
+ const after = (0, label_command_1.getLabelCacheStats)();
187
+ expect(after.cacheSize).toBe(2);
188
+ expect(after.inFlight).toBe(0); // 완료 후
189
+ });
190
+ test('in-flight 동안 stats.inFlight 증가', async () => {
191
+ buildDelay = 100;
192
+ const promise = (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, context: ctx });
193
+ // 시작 직후 — buildDelay 안에
194
+ await new Promise(r => setImmediate(r));
195
+ const during = (0, label_command_1.getLabelCacheStats)();
196
+ expect(during.inFlight).toBeGreaterThanOrEqual(1);
197
+ await promise;
198
+ const after = (0, label_command_1.getLabelCacheStats)();
199
+ expect(after.inFlight).toBe(0);
200
+ });
201
+ test('성공 캐시 후 동일 요청 100 회 동시 → cache hit 만 (build 0 회 추가)', async () => {
202
+ // 첫 호출로 캐시 채움
203
+ await (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, context: ctx });
204
+ expect(buildCalls).toHaveLength(1);
205
+ // 동시 100 회 — 모두 cache hit
206
+ const results = await Promise.all(Array.from({ length: 100 }, () => (0, label_command_1.labelcommand)({ id: 'lbl1', data: {}, context: ctx })));
207
+ expect(buildCalls).toHaveLength(1);
208
+ expect(new Set(results).size).toBe(1);
209
+ });
210
+ });
211
+ //# sourceMappingURL=label-command.test.js.map