#
tokens: 45365/50000 11/73 files (page 2/8)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 8. Use http://codebase.md/1yhy/figma-context-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .editorconfig
├── .env.example
├── .github
│   ├── ISSUE_TEMPLATE
│   │   ├── bug_report.yml
│   │   └── feature_request.yml
│   ├── PULL_REQUEST_TEMPLATE.md
│   └── workflows
│       └── ci.yml
├── .gitignore
├── .husky
│   ├── commit-msg
│   └── pre-commit
├── .lintstagedrc.json
├── .nvmrc
├── .prettierrc
├── CHANGELOG.md
├── commitlint.config.js
├── CONTRIBUTING.md
├── Dockerfile
├── docs
│   ├── en
│   │   ├── absolute-to-relative-research.md
│   │   ├── architecture.md
│   │   ├── cache-architecture.md
│   │   ├── grid-layout-research.md
│   │   ├── icon-detection.md
│   │   ├── layout-detection-research.md
│   │   └── layout-detection.md
│   └── zh-CN
│       ├── absolute-to-relative-research.md
│       ├── architecture.md
│       ├── cache-architecture.md
│       ├── grid-layout-research.md
│       ├── icon-detection.md
│       ├── layout-detection-research.md
│       ├── layout-detection.md
│       └── TODO-feature-enhancements.md
├── eslint.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── README.zh-CN.md
├── scripts
│   ├── fetch-test-data.ts
│   └── optimize-figma-json.ts
├── smithery.yaml
├── src
│   ├── algorithms
│   │   ├── icon
│   │   │   ├── detector.ts
│   │   │   └── index.ts
│   │   └── layout
│   │       ├── detector.ts
│   │       ├── index.ts
│   │       ├── optimizer.ts
│   │       └── spatial.ts
│   ├── config.ts
│   ├── core
│   │   ├── effects.ts
│   │   ├── layout.ts
│   │   ├── parser.ts
│   │   └── style.ts
│   ├── index.ts
│   ├── prompts
│   │   ├── design-to-code.ts
│   │   └── index.ts
│   ├── resources
│   │   ├── figma-resources.ts
│   │   └── index.ts
│   ├── server.ts
│   ├── services
│   │   ├── cache
│   │   │   ├── cache-manager.ts
│   │   │   ├── disk-cache.ts
│   │   │   ├── index.ts
│   │   │   ├── lru-cache.ts
│   │   │   └── types.ts
│   │   ├── cache.ts
│   │   ├── figma.ts
│   │   └── simplify-node-response.ts
│   ├── types
│   │   ├── figma.ts
│   │   ├── index.ts
│   │   └── simplified.ts
│   └── utils
│       ├── color.ts
│       ├── css.ts
│       ├── file.ts
│       └── validation.ts
├── tests
│   ├── fixtures
│   │   ├── expected
│   │   │   ├── node-240-32163-optimized.json
│   │   │   ├── node-402-34955-optimized.json
│   │   │   └── real-node-data-optimized.json
│   │   └── figma-data
│   │       ├── node-240-32163.json
│   │       ├── node-402-34955.json
│   │       └── real-node-data.json
│   ├── integration
│   │   ├── __snapshots__
│   │   │   ├── layout-optimization.test.ts.snap
│   │   │   └── output-quality.test.ts.snap
│   │   ├── layout-optimization.test.ts
│   │   ├── output-quality.test.ts
│   │   └── parser.test.ts
│   ├── unit
│   │   ├── algorithms
│   │   │   ├── icon-optimization.test.ts
│   │   │   ├── icon.test.ts
│   │   │   └── layout.test.ts
│   │   ├── resources
│   │   │   └── figma-resources.test.ts
│   │   └── services
│   │       └── cache.test.ts
│   └── utils
│       ├── preview-generator.ts
│       ├── preview.ts
│       ├── run-simplification.ts
│       └── viewer.html
├── tsconfig.json
├── tsup.config.ts
└── vitest.config.ts
```

# Files

--------------------------------------------------------------------------------
/docs/en/absolute-to-relative-research.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Absolute Position to Margin/Padding Conversion Research
  2 | 
  3 | ## Research Overview
  4 | 
  5 | This document summarizes industry implementations and academic research on converting absolute positioning (`position: absolute` + `left/top`) to relative layouts (`margin`, `padding`, `gap`).
  6 | 
  7 | ## 1. Industry Implementations
  8 | 
  9 | ### 1.1 FigmaToCode (bernaferrari/FigmaToCode)
 10 | 
 11 | **Approach**: AltNodes Intermediate Representation
 12 | 
 13 | **Key Points**:
 14 | 
 15 | - Uses a 4-stage transformation pipeline:
 16 | 
 17 |   1. Node Conversion - Figma nodes → JSON with optimizations
 18 |   2. Intermediate Representation - JSON → AltNodes (virtual DOM)
 19 |   3. Layout Optimization - Detect auto-layouts, responsive constraints
 20 |   4. Code Generation - Framework-specific output
 21 | 
 22 | - For complex layouts (absolute + auto-layout), makes "intelligent decisions about structure"
 23 | - Detects parent-child relationships and z-index ordering
 24 | - Uses `insets` for best cases, `left/top` for worst cases
 25 | 
 26 | **Source**: https://github.com/bernaferrari/FigmaToCode
 27 | 
 28 | ### 1.2 Facebook Yoga Layout Engine
 29 | 
 30 | **Approach**: CSS Flexbox Implementation in C++
 31 | 
 32 | **Padding/Margin Calculation**:
 33 | 
 34 | ```
 35 | paddingAndBorderForAxis = leadingPaddingAndBorder + trailingPaddingAndBorder
 36 | marginForAxis = leadingMargin + trailingMargin
 37 | ```
 38 | 
 39 | **Resolution Algorithm**:
 40 | 
 41 | - UnitPoint: Direct pixel value
 42 | - UnitPercent: `value * parentSize / 100`
 43 | - UnitAuto: Returns 0 for margins
 44 | 
 45 | **Key Functions**:
 46 | 
 47 | - `nodeLeadingPadding()` - Leading edge padding
 48 | - `nodeTrailingPadding()` - Trailing edge padding
 49 | - `nodeMarginForAxis()` - Total margin for axis
 50 | - `resolveValue()` - Unit conversion
 51 | 
 52 | **Source**: https://github.com/facebook/yoga
 53 | 
 54 | ### 1.3 imgcook (Alibaba)
 55 | 
 56 | **Approach**: Rule-based + CV-based Layout Algorithm
 57 | 
 58 | **Key Points**:
 59 | 
 60 | - Converts design layers to flat JSON with absolute positions
 61 | - Uses rule-based algorithms to merge adjacent rows/blocks
 62 | - CV-based approach for generalization (pixel-level comparison)
 63 | - No specific formulas disclosed for margin/padding calculation
 64 | 
 65 | **Limitation**: Implementation details not publicly available
 66 | 
 67 | **Source**: https://www.alibabacloud.com/blog/imgcook-3-0-series-layout-algorithm-design-based-code-generation_597856
 68 | 
 69 | ### 1.4 teleportHQ UIDL
 70 | 
 71 | **Approach**: Abstract UI Description Language
 72 | 
 73 | **Key Points**:
 74 | 
 75 | - CSS-like style properties in UIDL
 76 | - Design tokens for spacing constants (→ CSS variables)
 77 | - Does not appear to handle coordinate-based conversion
 78 | 
 79 | **Source**: https://github.com/teleporthq/teleport-code-generators
 80 | 
 81 | ## 2. Academic Research
 82 | 
 83 | ### 2.1 Layout Inference Algorithm for GUIs
 84 | 
 85 | **Paper**: "A layout inference algorithm for Graphical User Interfaces" (2015)
 86 | 
 87 | **Approach**: Two-phase algorithm using Allen's Interval Algebra
 88 | 
 89 | **Phase 1: Coordinate → Relative Positioning**
 90 | 
 91 | - Change coordinate-based positioning to relative positioning
 92 | - Use directed graphs and Allen relations
 93 | - Build spatial relationships between elements
 94 | 
 95 | **Phase 2: Pattern Matching & Graph Rewriting**
 96 | 
 97 | - Apply exploratory algorithm
 98 | - Pattern matching to identify layout structures
 99 | - Graph rewriting to obtain layout solutions
100 | 
101 | **Results**:
102 | 
103 | - 97% faithful to original views
104 | - 84% maintain proportions when resized
105 | 
106 | **Source**: https://www.sciencedirect.com/science/article/abs/pii/S0950584915001718
107 | 
108 | ### 2.2 Allen's Interval Algebra
109 | 
110 | **13 Basic Relations** (applicable to spatial layout):
111 | 
112 | | Relation      | Symbol | Description                                     |
113 | | ------------- | ------ | ----------------------------------------------- |
114 | | Precedes      | p      | A ends before B starts (gap between)            |
115 | | Meets         | m      | A ends exactly where B starts                   |
116 | | Overlaps      | o      | A starts before B, they overlap, B ends after A |
117 | | Finished-by   | F      | B starts during A, ends together                |
118 | | Contains      | D      | A completely contains B                         |
119 | | Starts        | s      | A and B start together, A ends first            |
120 | | Equals        | e      | A and B identical                               |
121 | | Started-by    | S      | B starts when A starts, B ends after            |
122 | | During        | d      | B completely contains A                         |
123 | | Finishes      | f      | A starts during B, ends together                |
124 | | Overlapped-by | O      | B starts before A, they overlap                 |
125 | | Met-by        | M      | A starts exactly where B ends                   |
126 | | Preceded-by   | P      | B ends before A starts (gap between)            |
127 | 
128 | **Application**: Determine spatial relationships to infer layout structure
129 | 
130 | **Source**: https://ics.uci.edu/~alspaugh/cls/shr/allen.html
131 | 
132 | ## 3. Key Insights
133 | 
134 | ### 3.1 Padding Inference Formula
135 | 
136 | When a parent container has children, padding can be inferred:
137 | 
138 | ```
139 | paddingTop = firstChild.y - parent.y
140 | paddingLeft = firstChild.x - parent.x
141 | paddingBottom = (parent.y + parent.height) - (lastChild.y + lastChild.height)
142 | paddingRight = (parent.x + parent.width) - (lastChild.x + lastChild.width)
143 | ```
144 | 
145 | **For Row Layout** (flex-direction: row):
146 | 
147 | - Sort children by X position
148 | - `paddingLeft` = first child's left offset from parent
149 | - `paddingTop` = minimum top offset among children
150 | - `gap` = consistent spacing between children (already implemented)
151 | 
152 | **For Column Layout** (flex-direction: column):
153 | 
154 | - Sort children by Y position
155 | - `paddingTop` = first child's top offset from parent
156 | - `paddingLeft` = minimum left offset among children
157 | - `gap` = consistent spacing between children (already implemented)
158 | 
159 | ### 3.2 Individual Margin Calculation
160 | 
161 | For elements that don't align perfectly with the primary axis:
162 | 
163 | ```
164 | For Row Layout:
165 |   expectedY = parent.y + paddingTop
166 |   marginTop = child.y - expectedY
167 | 
168 | For Column Layout:
169 |   expectedX = parent.x + paddingLeft
170 |   marginLeft = child.x - expectedX
171 | ```
172 | 
173 | ### 3.3 Cross-Axis Alignment vs Margin
174 | 
175 | When elements have different cross-axis positions:
176 | 
177 | **Option A: Use align-items + individual margins**
178 | 
179 | ```css
180 | .parent {
181 |   display: flex;
182 |   align-items: flex-start;
183 | }
184 | .child-offset {
185 |   margin-top: 10px; /* Individual offset */
186 | }
187 | ```
188 | 
189 | **Option B: Use align-items: center/stretch**
190 | If all elements are centered or stretched, no individual margins needed.
191 | 
192 | ### 3.4 Absolute Position Preservation
193 | 
194 | Some elements MUST keep absolute positioning:
195 | 
196 | - Overlapping elements (IoU > 0.1)
197 | - Stacked elements (z-index layering)
198 | - Elements outside parent bounds
199 | - Decorative/background elements
200 | 
201 | ## 4. Proposed Algorithm
202 | 
203 | ### Step 1: Classify Elements
204 | 
205 | ```
206 | For each parent with children:
207 |   1. Detect overlapping elements (IoU > 0.1) → Keep absolute
208 |   2. Remaining elements → Flow elements
209 | ```
210 | 
211 | ### Step 2: Detect Layout Direction
212 | 
213 | ```
214 | For flow elements:
215 |   1. Analyze horizontal vs vertical distribution
216 |   2. Determine primary axis (row or column)
217 |   3. Calculate gap consistency
218 | ```
219 | 
220 | ### Step 3: Calculate Padding
221 | 
222 | ```
223 | If layout detected:
224 |   1. Sort children by primary axis position
225 |   2. paddingStart = first child offset from parent start
226 |   3. paddingEnd = parent end - last child end
227 |   4. Analyze cross-axis for paddingCross
228 | ```
229 | 
230 | ### Step 4: Calculate Individual Margins
231 | 
232 | ```
233 | For each flow child:
234 |   1. expectedPosition = based on padding + gap + previous elements
235 |   2. actualPosition = child's current position
236 |   3. If difference > threshold:
237 |      - Add margin to child
238 | ```
239 | 
240 | ### Step 5: Clean Up Styles
241 | 
242 | ```
243 | For flow children:
244 |   1. Remove position: absolute
245 |   2. Remove left, top
246 |   3. Add margin if calculated
247 | 
248 | For parent:
249 |   1. Add padding
250 |   2. Keep gap (already implemented)
251 |   3. Keep display: flex/grid
252 | ```
253 | 
254 | ## 5. Implementation Considerations
255 | 
256 | ### 5.1 Edge Cases
257 | 
258 | 1. **Negative margins**: When elements overlap slightly
259 | 2. **Mixed alignments**: Some elements centered, others not
260 | 3. **Variable gaps**: Elements with inconsistent spacing
261 | 4. **Percentage values**: May need to convert px to %
262 | 
263 | ### 5.2 Thresholds
264 | 
265 | | Parameter             | Recommended Value | Source   |
266 | | --------------------- | ----------------- | -------- |
267 | | Padding detection     | >= 0px            | Standard |
268 | | Gap consistency CV    | <= 20%            | imgcook  |
269 | | Alignment tolerance   | 2px               | Common   |
270 | | Overlap IoU threshold | 0.1               | imgcook  |
271 | 
272 | ### 5.3 Priority Order
273 | 
274 | 1. Grid detection (highest priority for regular grids)
275 | 2. Flex detection with padding inference
276 | 3. Fall back to absolute positioning
277 | 
278 | ## 6. References
279 | 
280 | 1. **FigmaToCode**: https://github.com/bernaferrari/FigmaToCode
281 | 2. **Facebook Yoga**: https://github.com/facebook/yoga
282 | 3. **imgcook Blog**: https://www.alibabacloud.com/blog/imgcook-3-0-series-layout-algorithm-design-based-code-generation_597856
283 | 4. **Allen's Interval Algebra**: https://en.wikipedia.org/wiki/Allen's_interval_algebra
284 | 5. **Layout Inference Paper**: https://www.sciencedirect.com/science/article/abs/pii/S0950584915001718
285 | 6. **teleportHQ**: https://github.com/teleporthq/teleport-code-generators
286 | 7. **Yoga Go Port**: https://github.com/kjk/flex
287 | 
288 | ## 7. Conclusion
289 | 
290 | The key insight from industry implementations is that converting absolute positioning to relative layouts requires:
291 | 
292 | 1. **Spatial Analysis**: Use Allen's Interval Algebra or similar to understand element relationships
293 | 2. **Padding Inference**: Calculate parent padding from first/last child offsets
294 | 3. **Margin Calculation**: Handle individual element offsets that don't fit the primary layout
295 | 4. **Selective Preservation**: Keep absolute positioning for genuinely overlapping elements
296 | 
297 | The algorithm should be conservative - only convert when confident about the layout structure, otherwise preserve absolute positioning for accuracy.
298 | 
```

--------------------------------------------------------------------------------
/tests/fixtures/expected/real-node-data-optimized.json:
--------------------------------------------------------------------------------

```json
  1 | {
  2 |   "name": "Vigilkids产品站",
  3 |   "lastModified": "2025-12-05T09:47:37Z",
  4 |   "thumbnailUrl": "https://s3-alpha.figma.com/thumbnails/9a38a8e4-5a00-4c07-a053-e71c7103a167?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAQ4GOSFWCXJW6HYPC%2F20251204%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251204T000000Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dab594780137ae82433ff7205024276c20a3abb73855f2e393c7e6373086b892",
  5 |   "nodes": [
  6 |     {
  7 |       "id": "2:674",
  8 |       "name": "Group 1410104851",
  9 |       "type": "GROUP",
 10 |       "cssStyles": {
 11 |         "width": "1580px",
 12 |         "height": "895px",
 13 |         "position": "absolute",
 14 |         "left": "406px",
 15 |         "top": "422px"
 16 |       },
 17 |       "children": [
 18 |         {
 19 |           "id": "2:675",
 20 |           "name": "Rectangle 34",
 21 |           "type": "RECTANGLE",
 22 |           "cssStyles": {
 23 |             "width": "1580px",
 24 |             "height": "895px",
 25 |             "position": "absolute",
 26 |             "left": "0px",
 27 |             "top": "0px",
 28 |             "backgroundColor": "#FFFFFF",
 29 |             "borderRadius": "12px"
 30 |           }
 31 |         },
 32 |         {
 33 |           "id": "2:676",
 34 |           "name": "Group 1410104850",
 35 |           "type": "GROUP",
 36 |           "cssStyles": {
 37 |             "width": "350px",
 38 |             "height": "316px",
 39 |             "position": "absolute",
 40 |             "left": "615px",
 41 |             "top": "232px",
 42 |             "display": "flex",
 43 |             "flexDirection": "column",
 44 |             "gap": "32px",
 45 |             "justifyContent": "space-between",
 46 |             "alignItems": "center"
 47 |           },
 48 |           "children": [
 49 |             {
 50 |               "id": "2:689",
 51 |               "name": "Group 1410104849",
 52 |               "type": "GROUP",
 53 |               "cssStyles": {
 54 |                 "width": "220px",
 55 |                 "height": "138px",
 56 |                 "position": "absolute",
 57 |                 "left": "65px",
 58 |                 "top": "0px"
 59 |               },
 60 |               "exportInfo": {
 61 |                 "type": "IMAGE",
 62 |                 "format": "PNG",
 63 |                 "fileName": "group_1410104849.png"
 64 |               }
 65 |             },
 66 |             {
 67 |               "id": "2:677",
 68 |               "name": "Group 1410104480",
 69 |               "type": "GROUP",
 70 |               "cssStyles": {
 71 |                 "width": "350px",
 72 |                 "height": "148px",
 73 |                 "position": "absolute",
 74 |                 "left": "0px",
 75 |                 "top": "168px",
 76 |                 "display": "flex",
 77 |                 "flexDirection": "column",
 78 |                 "gap": "32px",
 79 |                 "justifyContent": "space-between",
 80 |                 "alignItems": "center"
 81 |               },
 82 |               "children": [
 83 |                 {
 84 |                   "id": "2:678",
 85 |                   "name": "添加自定义关键词,当检测到关键词出现时您将接收警报",
 86 |                   "type": "TEXT",
 87 |                   "cssStyles": {
 88 |                     "width": "350px",
 89 |                     "height": "20px",
 90 |                     "position": "absolute",
 91 |                     "left": "0px",
 92 |                     "top": "0px",
 93 |                     "color": "#333333",
 94 |                     "fontFamily": "PingFang SC",
 95 |                     "fontSize": "14px",
 96 |                     "fontWeight": 500,
 97 |                     "textAlign": "center",
 98 |                     "verticalAlign": "middle",
 99 |                     "lineHeight": "20px"
100 |                   },
101 |                   "text": "添加自定义关键词,当检测到关键词出现时您将接收警报"
102 |                 },
103 |                 {
104 |                   "id": "2:679",
105 |                   "name": "Group 1410104479",
106 |                   "type": "GROUP",
107 |                   "cssStyles": {
108 |                     "width": "240px",
109 |                     "height": "98px",
110 |                     "position": "absolute",
111 |                     "left": "55px",
112 |                     "top": "50px",
113 |                     "display": "flex",
114 |                     "flexDirection": "column",
115 |                     "gap": "10px",
116 |                     "justifyContent": "space-between",
117 |                     "alignItems": "flex-start"
118 |                   },
119 |                   "children": [
120 |                     {
121 |                       "id": "2:680",
122 |                       "name": "Group 1410086131",
123 |                       "type": "GROUP",
124 |                       "cssStyles": {
125 |                         "width": "240px",
126 |                         "height": "44px",
127 |                         "position": "absolute",
128 |                         "left": "0px",
129 |                         "top": "0px"
130 |                       },
131 |                       "children": [
132 |                         {
133 |                           "id": "2:681",
134 |                           "name": "Rectangle 34625783",
135 |                           "type": "RECTANGLE",
136 |                           "cssStyles": {
137 |                             "width": "240px",
138 |                             "height": "44px",
139 |                             "position": "absolute",
140 |                             "left": "0px",
141 |                             "top": "0px",
142 |                             "backgroundColor": "#24C790",
143 |                             "borderRadius": "10px"
144 |                           }
145 |                         },
146 |                         {
147 |                           "id": "2:682",
148 |                           "name": "Group 1410104509",
149 |                           "type": "GROUP",
150 |                           "cssStyles": {
151 |                             "width": "115px",
152 |                             "height": "20px",
153 |                             "position": "absolute",
154 |                             "left": "63px",
155 |                             "top": "12px",
156 |                             "display": "flex",
157 |                             "gap": "10px",
158 |                             "justifyContent": "space-between",
159 |                             "alignItems": "flex-start"
160 |                           },
161 |                           "children": [
162 |                             {
163 |                               "id": "2:684",
164 |                               "name": "Frame",
165 |                               "type": "FRAME",
166 |                               "cssStyles": {
167 |                                 "width": "20px",
168 |                                 "height": "20px",
169 |                                 "position": "absolute",
170 |                                 "left": "0px",
171 |                                 "top": "0px"
172 |                               },
173 |                               "exportInfo": {
174 |                                 "type": "IMAGE",
175 |                                 "format": "SVG",
176 |                                 "fileName": "frame.svg"
177 |                               }
178 |                             },
179 |                             {
180 |                               "id": "2:683",
181 |                               "name": "AI生成关键词",
182 |                               "type": "TEXT",
183 |                               "cssStyles": {
184 |                                 "width": "84px",
185 |                                 "height": "16px",
186 |                                 "position": "absolute",
187 |                                 "left": "31px",
188 |                                 "top": "2px",
189 |                                 "color": "#FFFFFF",
190 |                                 "fontFamily": "Roboto",
191 |                                 "fontSize": "14px",
192 |                                 "fontWeight": 700,
193 |                                 "textAlign": "center",
194 |                                 "verticalAlign": "middle",
195 |                                 "lineHeight": "16px"
196 |                               },
197 |                               "text": "AI生成关键词"
198 |                             }
199 |                           ]
200 |                         }
201 |                       ]
202 |                     },
203 |                     {
204 |                       "id": "2:686",
205 |                       "name": "Group 1410104451",
206 |                       "type": "GROUP",
207 |                       "cssStyles": {
208 |                         "width": "240px",
209 |                         "height": "44px",
210 |                         "position": "absolute",
211 |                         "left": "0px",
212 |                         "top": "54px",
213 |                         "borderRadius": "10px"
214 |                       },
215 |                       "children": [
216 |                         {
217 |                           "id": "2:687",
218 |                           "name": "Rectangle 34625783",
219 |                           "type": "RECTANGLE",
220 |                           "cssStyles": {
221 |                             "width": "240px",
222 |                             "height": "44px",
223 |                             "position": "absolute",
224 |                             "left": "0px",
225 |                             "top": "0px",
226 |                             "backgroundColor": "#FFFFFF",
227 |                             "borderColor": "#C4C4C4",
228 |                             "borderStyle": "solid",
229 |                             "borderRadius": "10px"
230 |                           }
231 |                         },
232 |                         {
233 |                           "id": "2:688",
234 |                           "name": "添加自定义关键词",
235 |                           "type": "TEXT",
236 |                           "cssStyles": {
237 |                             "width": "146px",
238 |                             "height": "16px",
239 |                             "position": "absolute",
240 |                             "left": "48px",
241 |                             "top": "14px",
242 |                             "color": "#333333",
243 |                             "fontFamily": "Roboto",
244 |                             "fontSize": "14px",
245 |                             "fontWeight": 700,
246 |                             "textAlign": "center",
247 |                             "verticalAlign": "middle",
248 |                             "lineHeight": "16px"
249 |                           },
250 |                           "text": "添加自定义关键词"
251 |                         }
252 |                       ]
253 |                     }
254 |                   ]
255 |                 }
256 |               ]
257 |             }
258 |           ]
259 |         }
260 |       ]
261 |     }
262 |   ]
263 | }
264 | 
```

--------------------------------------------------------------------------------
/tests/unit/algorithms/icon-optimization.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Icon Detection Optimization Tests
  3 |  *
  4 |  * Verifies that the optimized collectNodeStats() function produces
  5 |  * identical results to the original individual functions.
  6 |  */
  7 | 
  8 | import { describe, it, expect } from "vitest";
  9 | 
 10 | // Test the internal implementation
 11 | // We'll create mock nodes and verify the stats are correctly computed
 12 | 
 13 | interface MockNode {
 14 |   id: string;
 15 |   name: string;
 16 |   type: string;
 17 |   children?: MockNode[];
 18 |   absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
 19 |   fills?: Array<{ type: string; visible?: boolean; imageRef?: string }>;
 20 |   effects?: Array<{ type: string; visible?: boolean }>;
 21 | }
 22 | 
 23 | // Helper functions to test (matching the original implementations)
 24 | const CONTAINER_TYPES = ["GROUP", "FRAME", "COMPONENT", "INSTANCE"];
 25 | const MERGEABLE_TYPES = [
 26 |   "VECTOR",
 27 |   "RECTANGLE",
 28 |   "ELLIPSE",
 29 |   "LINE",
 30 |   "POLYGON",
 31 |   "STAR",
 32 |   "BOOLEAN_OPERATION",
 33 |   "REGULAR_POLYGON",
 34 | ];
 35 | const EXCLUDE_TYPES = ["TEXT", "COMPONENT", "INSTANCE"];
 36 | const PNG_REQUIRED_EFFECTS = ["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"];
 37 | 
 38 | function isContainerType(type: string): boolean {
 39 |   return CONTAINER_TYPES.includes(type);
 40 | }
 41 | 
 42 | function isMergeableType(type: string): boolean {
 43 |   return MERGEABLE_TYPES.includes(type);
 44 | }
 45 | 
 46 | function isExcludeType(type: string): boolean {
 47 |   return EXCLUDE_TYPES.includes(type);
 48 | }
 49 | 
 50 | function hasImageFill(node: MockNode): boolean {
 51 |   if (!node.fills) return false;
 52 |   return node.fills.some(
 53 |     (fill) => fill.type === "IMAGE" && fill.visible !== false && fill.imageRef,
 54 |   );
 55 | }
 56 | 
 57 | function hasComplexEffects(node: MockNode): boolean {
 58 |   if (!node.effects) return false;
 59 |   return node.effects.some(
 60 |     (effect) => effect.visible !== false && PNG_REQUIRED_EFFECTS.includes(effect.type),
 61 |   );
 62 | }
 63 | 
 64 | // Original functions (for comparison)
 65 | function calculateDepthOriginal(node: MockNode, currentDepth: number = 0): number {
 66 |   if (!node.children || node.children.length === 0) {
 67 |     return currentDepth;
 68 |   }
 69 |   return Math.max(...node.children.map((child) => calculateDepthOriginal(child, currentDepth + 1)));
 70 | }
 71 | 
 72 | function countTotalChildrenOriginal(node: MockNode): number {
 73 |   if (!node.children || node.children.length === 0) {
 74 |     return 0;
 75 |   }
 76 |   return node.children.reduce((sum, child) => sum + 1 + countTotalChildrenOriginal(child), 0);
 77 | }
 78 | 
 79 | function hasExcludeTypeInTreeOriginal(node: MockNode): boolean {
 80 |   if (isExcludeType(node.type)) {
 81 |     return true;
 82 |   }
 83 |   if (node.children) {
 84 |     return node.children.some((child) => hasExcludeTypeInTreeOriginal(child));
 85 |   }
 86 |   return false;
 87 | }
 88 | 
 89 | function hasImageFillInTreeOriginal(node: MockNode): boolean {
 90 |   if (hasImageFill(node)) {
 91 |     return true;
 92 |   }
 93 |   if (node.children) {
 94 |     return node.children.some((child) => hasImageFillInTreeOriginal(child));
 95 |   }
 96 |   return false;
 97 | }
 98 | 
 99 | function hasComplexEffectsInTreeOriginal(node: MockNode): boolean {
100 |   if (hasComplexEffects(node)) {
101 |     return true;
102 |   }
103 |   if (node.children) {
104 |     return node.children.some((child) => hasComplexEffectsInTreeOriginal(child));
105 |   }
106 |   return false;
107 | }
108 | 
109 | function areAllLeavesMergeableOriginal(node: MockNode): boolean {
110 |   if (!node.children || node.children.length === 0) {
111 |     return isMergeableType(node.type);
112 |   }
113 |   if (isContainerType(node.type)) {
114 |     return node.children.every((child) => areAllLeavesMergeableOriginal(child));
115 |   }
116 |   return isMergeableType(node.type);
117 | }
118 | 
119 | // Optimized single-pass function
120 | interface NodeTreeStats {
121 |   depth: number;
122 |   totalChildren: number;
123 |   hasExcludeType: boolean;
124 |   hasImageFill: boolean;
125 |   hasComplexEffects: boolean;
126 |   allLeavesMergeable: boolean;
127 |   mergeableRatio: number;
128 | }
129 | 
130 | function collectNodeStats(node: MockNode): NodeTreeStats {
131 |   if (!node.children || node.children.length === 0) {
132 |     const isMergeable = isMergeableType(node.type);
133 |     return {
134 |       depth: 0,
135 |       totalChildren: 0,
136 |       hasExcludeType: isExcludeType(node.type),
137 |       hasImageFill: hasImageFill(node),
138 |       hasComplexEffects: hasComplexEffects(node),
139 |       allLeavesMergeable: isMergeable,
140 |       mergeableRatio: isMergeable ? 1 : 0,
141 |     };
142 |   }
143 | 
144 |   const childStats = node.children.map(collectNodeStats);
145 |   const maxChildDepth = Math.max(...childStats.map((s) => s.depth));
146 |   const totalDescendants = childStats.reduce((sum, s) => sum + 1 + s.totalChildren, 0);
147 |   const hasExcludeInChildren = childStats.some((s) => s.hasExcludeType);
148 |   const hasImageInChildren = childStats.some((s) => s.hasImageFill);
149 |   const hasEffectsInChildren = childStats.some((s) => s.hasComplexEffects);
150 |   const allChildrenMergeable = childStats.every((s) => s.allLeavesMergeable);
151 | 
152 |   const mergeableCount = node.children.filter(
153 |     (child) => isMergeableType(child.type) || isContainerType(child.type),
154 |   ).length;
155 |   const mergeableRatio = mergeableCount / node.children.length;
156 | 
157 |   const allLeavesMergeable = isContainerType(node.type)
158 |     ? allChildrenMergeable
159 |     : isMergeableType(node.type);
160 | 
161 |   return {
162 |     depth: maxChildDepth + 1,
163 |     totalChildren: totalDescendants,
164 |     hasExcludeType: isExcludeType(node.type) || hasExcludeInChildren,
165 |     hasImageFill: hasImageFill(node) || hasImageInChildren,
166 |     hasComplexEffects: hasComplexEffects(node) || hasEffectsInChildren,
167 |     allLeavesMergeable,
168 |     mergeableRatio,
169 |   };
170 | }
171 | 
172 | describe("Icon Detection Optimization", () => {
173 |   describe("collectNodeStats equivalence", () => {
174 |     const testCases: { name: string; node: MockNode }[] = [
175 |       {
176 |         name: "simple leaf node",
177 |         node: {
178 |           id: "1",
179 |           name: "Vector",
180 |           type: "VECTOR",
181 |         },
182 |       },
183 |       {
184 |         name: "leaf node with excludable type",
185 |         node: {
186 |           id: "1",
187 |           name: "Text",
188 |           type: "TEXT",
189 |         },
190 |       },
191 |       {
192 |         name: "container with vector children",
193 |         node: {
194 |           id: "1",
195 |           name: "Group",
196 |           type: "GROUP",
197 |           children: [
198 |             { id: "2", name: "Vector1", type: "VECTOR" },
199 |             { id: "3", name: "Vector2", type: "VECTOR" },
200 |           ],
201 |         },
202 |       },
203 |       {
204 |         name: "nested container",
205 |         node: {
206 |           id: "1",
207 |           name: "Frame",
208 |           type: "FRAME",
209 |           children: [
210 |             {
211 |               id: "2",
212 |               name: "Group",
213 |               type: "GROUP",
214 |               children: [
215 |                 { id: "3", name: "Ellipse", type: "ELLIPSE" },
216 |                 { id: "4", name: "Rect", type: "RECTANGLE" },
217 |               ],
218 |             },
219 |           ],
220 |         },
221 |       },
222 |       {
223 |         name: "container with text child",
224 |         node: {
225 |           id: "1",
226 |           name: "Button",
227 |           type: "FRAME",
228 |           children: [
229 |             { id: "2", name: "BG", type: "RECTANGLE" },
230 |             { id: "3", name: "Label", type: "TEXT" },
231 |           ],
232 |         },
233 |       },
234 |       {
235 |         name: "node with image fill",
236 |         node: {
237 |           id: "1",
238 |           name: "Image",
239 |           type: "RECTANGLE",
240 |           fills: [{ type: "IMAGE", visible: true, imageRef: "abc123" }],
241 |         },
242 |       },
243 |       {
244 |         name: "node with complex effects",
245 |         node: {
246 |           id: "1",
247 |           name: "Shadow Box",
248 |           type: "FRAME",
249 |           effects: [{ type: "DROP_SHADOW", visible: true }],
250 |           children: [{ id: "2", name: "Content", type: "RECTANGLE" }],
251 |         },
252 |       },
253 |       {
254 |         name: "deeply nested structure",
255 |         node: {
256 |           id: "1",
257 |           name: "Root",
258 |           type: "FRAME",
259 |           children: [
260 |             {
261 |               id: "2",
262 |               name: "Level1",
263 |               type: "GROUP",
264 |               children: [
265 |                 {
266 |                   id: "3",
267 |                   name: "Level2",
268 |                   type: "GROUP",
269 |                   children: [
270 |                     {
271 |                       id: "4",
272 |                       name: "Level3",
273 |                       type: "GROUP",
274 |                       children: [{ id: "5", name: "Leaf", type: "VECTOR" }],
275 |                     },
276 |                   ],
277 |                 },
278 |               ],
279 |             },
280 |           ],
281 |         },
282 |       },
283 |     ];
284 | 
285 |     testCases.forEach(({ name, node }) => {
286 |       it(`should produce equivalent results for: ${name}`, () => {
287 |         const stats = collectNodeStats(node);
288 | 
289 |         // Compare with original functions
290 |         expect(stats.depth).toBe(calculateDepthOriginal(node));
291 |         expect(stats.totalChildren).toBe(countTotalChildrenOriginal(node));
292 |         expect(stats.hasExcludeType).toBe(hasExcludeTypeInTreeOriginal(node));
293 |         expect(stats.hasImageFill).toBe(hasImageFillInTreeOriginal(node));
294 |         expect(stats.hasComplexEffects).toBe(hasComplexEffectsInTreeOriginal(node));
295 |         expect(stats.allLeavesMergeable).toBe(areAllLeavesMergeableOriginal(node));
296 |       });
297 |     });
298 |   });
299 | 
300 |   describe("edge cases", () => {
301 |     it("should handle empty children array", () => {
302 |       const node: MockNode = {
303 |         id: "1",
304 |         name: "Empty",
305 |         type: "GROUP",
306 |         children: [],
307 |       };
308 | 
309 |       const stats = collectNodeStats(node);
310 |       expect(stats.depth).toBe(0);
311 |       expect(stats.totalChildren).toBe(0);
312 |     });
313 | 
314 |     it("should handle invisible fills", () => {
315 |       const node: MockNode = {
316 |         id: "1",
317 |         name: "Hidden Image",
318 |         type: "RECTANGLE",
319 |         fills: [{ type: "IMAGE", visible: false, imageRef: "abc123" }],
320 |       };
321 | 
322 |       const stats = collectNodeStats(node);
323 |       expect(stats.hasImageFill).toBe(false);
324 |     });
325 | 
326 |     it("should handle invisible effects", () => {
327 |       const node: MockNode = {
328 |         id: "1",
329 |         name: "Hidden Shadow",
330 |         type: "RECTANGLE",
331 |         effects: [{ type: "DROP_SHADOW", visible: false }],
332 |       };
333 | 
334 |       const stats = collectNodeStats(node);
335 |       expect(stats.hasComplexEffects).toBe(false);
336 |     });
337 | 
338 |     it("should calculate correct mergeable ratio", () => {
339 |       const node: MockNode = {
340 |         id: "1",
341 |         name: "Mixed",
342 |         type: "FRAME",
343 |         children: [
344 |           { id: "2", name: "V1", type: "VECTOR" },
345 |           { id: "3", name: "V2", type: "VECTOR" },
346 |           { id: "4", name: "Unknown", type: "UNKNOWN_TYPE" },
347 |           { id: "5", name: "G1", type: "GROUP" },
348 |         ],
349 |       };
350 | 
351 |       const stats = collectNodeStats(node);
352 |       // 3 mergeable (2 VECTOR + 1 GROUP) out of 4
353 |       expect(stats.mergeableRatio).toBe(0.75);
354 |     });
355 |   });
356 | });
357 | 
```

--------------------------------------------------------------------------------
/tests/unit/services/cache.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Cache Manager Unit Tests
  3 |  *
  4 |  * Tests the multi-layer caching system for Figma API responses and images.
  5 |  */
  6 | 
  7 | import { describe, it, expect, beforeEach, afterEach } from "vitest";
  8 | import * as fs from "fs";
  9 | import * as path from "path";
 10 | import * as os from "os";
 11 | import { CacheManager } from "~/services/cache/index.js";
 12 | 
 13 | describe("CacheManager", () => {
 14 |   let cacheManager: CacheManager;
 15 |   let testCacheDir: string;
 16 | 
 17 |   beforeEach(() => {
 18 |     // Create a unique temporary directory for each test
 19 |     testCacheDir = path.join(os.tmpdir(), `figma-cache-test-${Date.now()}`);
 20 |     cacheManager = new CacheManager({
 21 |       enabled: true,
 22 |       memory: {
 23 |         maxNodeItems: 100,
 24 |         maxImageItems: 50,
 25 |         nodeTTL: 1000, // 1 second TTL for testing
 26 |         imageTTL: 1000,
 27 |       },
 28 |       disk: {
 29 |         cacheDir: testCacheDir,
 30 |         maxSize: 100 * 1024 * 1024,
 31 |         ttl: 1000, // 1 second TTL for testing
 32 |       },
 33 |     });
 34 |   });
 35 | 
 36 |   afterEach(() => {
 37 |     // Clean up test cache directory
 38 |     if (fs.existsSync(testCacheDir)) {
 39 |       fs.rmSync(testCacheDir, { recursive: true, force: true });
 40 |     }
 41 |   });
 42 | 
 43 |   describe("Configuration", () => {
 44 |     it("should create cache directories on initialization", () => {
 45 |       expect(fs.existsSync(testCacheDir)).toBe(true);
 46 |       expect(fs.existsSync(path.join(testCacheDir, "data"))).toBe(true);
 47 |       expect(fs.existsSync(path.join(testCacheDir, "images"))).toBe(true);
 48 |       expect(fs.existsSync(path.join(testCacheDir, "metadata"))).toBe(true);
 49 |     });
 50 | 
 51 |     it("should not create directories when disabled", () => {
 52 |       const disabledCacheDir = path.join(os.tmpdir(), `figma-cache-disabled-${Date.now()}`);
 53 |       new CacheManager({
 54 |         enabled: false,
 55 |         disk: {
 56 |           cacheDir: disabledCacheDir,
 57 |           maxSize: 100 * 1024 * 1024,
 58 |           ttl: 1000,
 59 |         },
 60 |       });
 61 | 
 62 |       expect(fs.existsSync(disabledCacheDir)).toBe(false);
 63 |     });
 64 | 
 65 |     it("should return correct cache stats", async () => {
 66 |       const stats = await cacheManager.getStats();
 67 | 
 68 |       expect(stats.enabled).toBe(true);
 69 |       expect(stats.memory.size).toBe(0);
 70 |       expect(stats.disk.nodeFileCount).toBe(0);
 71 |       expect(stats.disk.imageFileCount).toBe(0);
 72 |       expect(stats.disk.totalSize).toBe(0);
 73 |     });
 74 | 
 75 |     it("should return cache directory", () => {
 76 |       expect(cacheManager.getCacheDir()).toBe(testCacheDir);
 77 |     });
 78 | 
 79 |     it("should report enabled status", () => {
 80 |       expect(cacheManager.isEnabled()).toBe(true);
 81 |     });
 82 |   });
 83 | 
 84 |   describe("Node Data Caching", () => {
 85 |     const testData = { id: "123", name: "Test Node", type: "FRAME" };
 86 |     const fileKey = "test-file-key";
 87 | 
 88 |     it("should cache and retrieve node data", async () => {
 89 |       await cacheManager.setNodeData(testData, fileKey);
 90 |       const cached = await cacheManager.getNodeData(fileKey);
 91 | 
 92 |       expect(cached).toEqual(testData);
 93 |     });
 94 | 
 95 |     it("should cache with nodeId parameter", async () => {
 96 |       const nodeId = "node-456";
 97 |       await cacheManager.setNodeData(testData, fileKey, nodeId);
 98 |       const cached = await cacheManager.getNodeData(fileKey, nodeId);
 99 | 
100 |       expect(cached).toEqual(testData);
101 |     });
102 | 
103 |     it("should cache with depth parameter", async () => {
104 |       const nodeId = "node-789";
105 |       const depth = 3;
106 |       await cacheManager.setNodeData(testData, fileKey, nodeId, depth);
107 |       const cached = await cacheManager.getNodeData(fileKey, nodeId, depth);
108 | 
109 |       expect(cached).toEqual(testData);
110 |     });
111 | 
112 |     it("should return null for non-existent cache", async () => {
113 |       const cached = await cacheManager.getNodeData("non-existent-key");
114 | 
115 |       expect(cached).toBeNull();
116 |     });
117 | 
118 |     it("should return null for expired cache", async () => {
119 |       await cacheManager.setNodeData(testData, fileKey);
120 | 
121 |       // Wait for cache to expire (TTL is 1 second)
122 |       await new Promise((resolve) => setTimeout(resolve, 1100));
123 | 
124 |       const cached = await cacheManager.getNodeData(fileKey);
125 |       expect(cached).toBeNull();
126 |     });
127 | 
128 |     it("should update cache stats after caching data", async () => {
129 |       await cacheManager.setNodeData(testData, fileKey);
130 |       const stats = await cacheManager.getStats();
131 | 
132 |       expect(stats.memory.size).toBe(1);
133 |       expect(stats.disk.nodeFileCount).toBe(1);
134 |       expect(stats.disk.totalSize).toBeGreaterThan(0);
135 |     });
136 | 
137 |     it("should check if node data exists", async () => {
138 |       expect(await cacheManager.hasNodeData(fileKey)).toBe(false);
139 |       await cacheManager.setNodeData(testData, fileKey);
140 |       expect(await cacheManager.hasNodeData(fileKey)).toBe(true);
141 |     });
142 |   });
143 | 
144 |   describe("Image Caching", () => {
145 |     const fileKey = "test-file";
146 |     const nodeId = "image-node";
147 |     const format = "png";
148 |     let testImagePath: string;
149 | 
150 |     beforeEach(() => {
151 |       // Create a test image file
152 |       testImagePath = path.join(os.tmpdir(), `test-image-${Date.now()}.png`);
153 |       fs.writeFileSync(testImagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
154 |     });
155 | 
156 |     afterEach(() => {
157 |       if (fs.existsSync(testImagePath)) {
158 |         fs.unlinkSync(testImagePath);
159 |       }
160 |     });
161 | 
162 |     it("should return null for uncached image", async () => {
163 |       const result = await cacheManager.hasImage(fileKey, nodeId, format);
164 |       expect(result).toBeNull();
165 |     });
166 | 
167 |     it("should cache and find image", async () => {
168 |       await cacheManager.cacheImage(testImagePath, fileKey, nodeId, format);
169 |       const cachedPath = await cacheManager.hasImage(fileKey, nodeId, format);
170 | 
171 |       expect(cachedPath).not.toBeNull();
172 |       expect(fs.existsSync(cachedPath!)).toBe(true);
173 |     });
174 | 
175 |     it("should copy image from cache to target path", async () => {
176 |       await cacheManager.cacheImage(testImagePath, fileKey, nodeId, format);
177 | 
178 |       const targetPath = path.join(os.tmpdir(), `target-${Date.now()}.png`);
179 |       const success = await cacheManager.copyImageFromCache(fileKey, nodeId, format, targetPath);
180 | 
181 |       expect(success).toBe(true);
182 |       expect(fs.existsSync(targetPath)).toBe(true);
183 | 
184 |       // Clean up
185 |       fs.unlinkSync(targetPath);
186 |     });
187 | 
188 |     it("should return false when copying non-existent image", async () => {
189 |       const targetPath = path.join(os.tmpdir(), `target-${Date.now()}.png`);
190 |       const success = await cacheManager.copyImageFromCache(
191 |         "non-existent",
192 |         "non-existent",
193 |         format,
194 |         targetPath,
195 |       );
196 | 
197 |       expect(success).toBe(false);
198 |     });
199 | 
200 |     it("should update cache stats after caching image", async () => {
201 |       await cacheManager.cacheImage(testImagePath, fileKey, nodeId, format);
202 |       const stats = await cacheManager.getStats();
203 | 
204 |       expect(stats.disk.imageFileCount).toBe(1);
205 |     });
206 |   });
207 | 
208 |   describe("Cache Cleanup", () => {
209 |     it("should clean expired cache entries", async () => {
210 |       const testData = { id: "test" };
211 |       await cacheManager.setNodeData(testData, "file-1");
212 | 
213 |       // Wait for cache to expire
214 |       await new Promise((resolve) => setTimeout(resolve, 1100));
215 | 
216 |       const result = await cacheManager.cleanExpired();
217 |       expect(result.disk).toBeGreaterThanOrEqual(1);
218 | 
219 |       const stats = await cacheManager.getStats();
220 |       expect(stats.disk.nodeFileCount).toBe(0);
221 |     });
222 | 
223 |     it("should clear all cache", async () => {
224 |       await cacheManager.setNodeData({ id: "1" }, "file-1");
225 |       await cacheManager.setNodeData({ id: "2" }, "file-2");
226 | 
227 |       await cacheManager.clearAll();
228 | 
229 |       const stats = await cacheManager.getStats();
230 |       expect(stats.memory.size).toBe(0);
231 |       expect(stats.disk.nodeFileCount).toBe(0);
232 |       expect(stats.disk.imageFileCount).toBe(0);
233 |     });
234 |   });
235 | 
236 |   describe("Cache Invalidation", () => {
237 |     it("should invalidate all entries for a file", async () => {
238 |       const fileKey = "test-file";
239 |       await cacheManager.setNodeData({ id: "1" }, fileKey, "node-1");
240 |       await cacheManager.setNodeData({ id: "2" }, fileKey, "node-2");
241 |       await cacheManager.setNodeData({ id: "3" }, "other-file", "node-3");
242 | 
243 |       const result = await cacheManager.invalidateFile(fileKey);
244 | 
245 |       expect(result.memory).toBe(2);
246 |       expect(await cacheManager.getNodeData(fileKey, "node-1")).toBeNull();
247 |       expect(await cacheManager.getNodeData(fileKey, "node-2")).toBeNull();
248 |       // Other file should still be cached
249 |       expect(await cacheManager.getNodeData("other-file", "node-3")).not.toBeNull();
250 |     });
251 | 
252 |     it("should invalidate a specific node", async () => {
253 |       const fileKey = "test-file";
254 |       await cacheManager.setNodeData({ id: "1" }, fileKey, "node-1");
255 |       await cacheManager.setNodeData({ id: "2" }, fileKey, "node-2");
256 | 
257 |       const result = await cacheManager.invalidateNode(fileKey, "node-1");
258 | 
259 |       expect(result.memory).toBe(1);
260 |       expect(await cacheManager.getNodeData(fileKey, "node-1")).toBeNull();
261 |       expect(await cacheManager.getNodeData(fileKey, "node-2")).not.toBeNull();
262 |     });
263 |   });
264 | 
265 |   describe("Disabled Cache", () => {
266 |     let disabledCacheManager: CacheManager;
267 | 
268 |     beforeEach(() => {
269 |       disabledCacheManager = new CacheManager({ enabled: false });
270 |     });
271 | 
272 |     it("should return null for getNodeData when disabled", async () => {
273 |       const result = await disabledCacheManager.getNodeData("any-key");
274 |       expect(result).toBeNull();
275 |     });
276 | 
277 |     it("should do nothing for setNodeData when disabled", async () => {
278 |       await disabledCacheManager.setNodeData({ id: "test" }, "file-key");
279 |       const result = await disabledCacheManager.getNodeData("file-key");
280 |       expect(result).toBeNull();
281 |     });
282 | 
283 |     it("should return null for hasImage when disabled", async () => {
284 |       const result = await disabledCacheManager.hasImage("file", "node", "png");
285 |       expect(result).toBeNull();
286 |     });
287 | 
288 |     it("should return source path for cacheImage when disabled", async () => {
289 |       const sourcePath = "/path/to/image.png";
290 |       const result = await disabledCacheManager.cacheImage(sourcePath, "file", "node", "png");
291 |       expect(result).toBe(sourcePath);
292 |     });
293 | 
294 |     it("should return zero for cleanExpired when disabled", async () => {
295 |       const result = await disabledCacheManager.cleanExpired();
296 |       expect(result.memory).toBe(0);
297 |       expect(result.disk).toBe(0);
298 |     });
299 | 
300 |     it("should report disabled in stats", async () => {
301 |       const stats = await disabledCacheManager.getStats();
302 |       expect(stats.enabled).toBe(false);
303 |     });
304 | 
305 |     it("should report disabled status", () => {
306 |       expect(disabledCacheManager.isEnabled()).toBe(false);
307 |     });
308 |   });
309 | 
310 |   describe("Memory Cache (L1)", () => {
311 |     it("should serve from memory cache on second read", async () => {
312 |       const testData = { id: "memory-test" };
313 |       const fileKey = "memory-file";
314 | 
315 |       await cacheManager.setNodeData(testData, fileKey);
316 | 
317 |       // First read - populates memory from disk if needed
318 |       const first = await cacheManager.getNodeData(fileKey);
319 |       expect(first).toEqual(testData);
320 | 
321 |       // Second read should hit memory cache
322 |       const second = await cacheManager.getNodeData(fileKey);
323 |       expect(second).toEqual(testData);
324 | 
325 |       const stats = await cacheManager.getStats();
326 |       expect(stats.memory.hits).toBeGreaterThan(0);
327 |     });
328 | 
329 |     it("should track cache statistics", async () => {
330 |       // Initial stats
331 |       let stats = await cacheManager.getStats();
332 |       expect(stats.memory.hits).toBe(0);
333 |       expect(stats.memory.misses).toBe(0);
334 | 
335 |       // Miss
336 |       await cacheManager.getNodeData("non-existent");
337 |       stats = await cacheManager.getStats();
338 |       expect(stats.memory.misses).toBe(1);
339 | 
340 |       // Set and hit
341 |       await cacheManager.setNodeData({ test: 1 }, "key");
342 |       await cacheManager.getNodeData("key");
343 |       stats = await cacheManager.getStats();
344 |       expect(stats.memory.hits).toBe(1);
345 |     });
346 | 
347 |     it("should reset statistics", async () => {
348 |       await cacheManager.setNodeData({ test: 1 }, "key");
349 |       await cacheManager.getNodeData("key");
350 |       await cacheManager.getNodeData("non-existent");
351 | 
352 |       cacheManager.resetStats();
353 |       const stats = await cacheManager.getStats();
354 |       expect(stats.memory.hits).toBe(0);
355 |       expect(stats.memory.misses).toBe(0);
356 |     });
357 |   });
358 | });
359 | 
```

--------------------------------------------------------------------------------
/tests/integration/output-quality.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Output Quality Validation Tests
  3 |  *
  4 |  * Tests the quality of optimized output for redundancy, consistency, and correctness.
  5 |  * Converted from scripts/analyze-optimized-output.ts
  6 |  */
  7 | 
  8 | import { describe, it, expect, beforeAll } from "vitest";
  9 | import * as fs from "fs";
 10 | import * as path from "path";
 11 | import { fileURLToPath } from "url";
 12 | import { parseFigmaResponse } from "~/core/parser.js";
 13 | import type { SimplifiedNode } from "~/types/index.js";
 14 | 
 15 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
 16 | const fixturesDir = path.join(__dirname, "../fixtures/figma-data");
 17 | 
 18 | // Test file configurations
 19 | const TEST_FILES = [
 20 |   { name: "node-402-34955", desc: "Group 1410104853 (1580x895)" },
 21 |   { name: "node-240-32163", desc: "Call Logs 有数据 (375x827)" },
 22 | ];
 23 | 
 24 | // Quality analysis interfaces
 25 | interface QualityAnalysis {
 26 |   totalNodes: number;
 27 |   nodesByType: Record<string, number>;
 28 |   layoutStats: {
 29 |     flex: number;
 30 |     grid: number;
 31 |     absolute: number;
 32 |     none: number;
 33 |   };
 34 |   cssPropertyUsage: Record<string, number>;
 35 |   redundantPatterns: RedundantPattern[];
 36 |   emptyOrDefaultValues: EmptyValue[];
 37 |   issues: QualityIssue[];
 38 | }
 39 | 
 40 | interface RedundantPattern {
 41 |   nodeName: string;
 42 |   pattern: string;
 43 |   details: string;
 44 | }
 45 | 
 46 | interface EmptyValue {
 47 |   nodeName: string;
 48 |   property: string;
 49 |   value: string;
 50 | }
 51 | 
 52 | interface QualityIssue {
 53 |   nodeName: string;
 54 |   nodeType: string;
 55 |   issue: string;
 56 |   severity: "warning" | "error";
 57 | }
 58 | 
 59 | // Helper: Analyze a node recursively
 60 | function analyzeNode(node: SimplifiedNode, result: QualityAnalysis, parentLayout?: string): void {
 61 |   result.totalNodes++;
 62 | 
 63 |   // Count node types
 64 |   result.nodesByType[node.type] = (result.nodesByType[node.type] || 0) + 1;
 65 | 
 66 |   // Count layout types
 67 |   const display = node.cssStyles?.display;
 68 |   if (display === "flex") {
 69 |     result.layoutStats.flex++;
 70 |   } else if (display === "grid") {
 71 |     result.layoutStats.grid++;
 72 |   } else if (node.cssStyles?.position === "absolute") {
 73 |     result.layoutStats.absolute++;
 74 |   } else {
 75 |     result.layoutStats.none++;
 76 |   }
 77 | 
 78 |   // Analyze CSS properties
 79 |   if (node.cssStyles) {
 80 |     for (const [key, value] of Object.entries(node.cssStyles)) {
 81 |       if (value !== undefined && value !== null && value !== "") {
 82 |         result.cssPropertyUsage[key] = (result.cssPropertyUsage[key] || 0) + 1;
 83 |       }
 84 | 
 85 |       // Check for empty or default values
 86 |       if (value === "" || value === "0" || value === "0px" || value === "none") {
 87 |         result.emptyOrDefaultValues.push({
 88 |           nodeName: node.name,
 89 |           property: key,
 90 |           value: String(value),
 91 |         });
 92 |       }
 93 | 
 94 |       // Check for redundant patterns
 95 |       // 1. position: absolute inside flex/grid parent
 96 |       if (key === "position" && value === "absolute" && parentLayout) {
 97 |         if (parentLayout === "flex" || parentLayout === "grid") {
 98 |           result.redundantPatterns.push({
 99 |             nodeName: node.name,
100 |             pattern: "absolute-in-layout",
101 |             details: `position:absolute inside ${parentLayout} parent`,
102 |           });
103 |         }
104 |       }
105 | 
106 |       // 2. width with flex property (potential conflict)
107 |       if (key === "width" && node.cssStyles?.flex) {
108 |         result.redundantPatterns.push({
109 |           nodeName: node.name,
110 |           pattern: "width-with-flex",
111 |           details: "width specified with flex property",
112 |         });
113 |       }
114 |     }
115 |   }
116 | 
117 |   // Check for quality issues
118 |   // 1. TEXT node with layout properties
119 |   if (node.type === "TEXT" && (display === "flex" || display === "grid")) {
120 |     result.issues.push({
121 |       nodeName: node.name,
122 |       nodeType: node.type,
123 |       issue: `TEXT node with ${display} layout (unnecessary)`,
124 |       severity: "warning",
125 |     });
126 |   }
127 | 
128 |   // 2. Empty children array
129 |   if (node.children && node.children.length === 0) {
130 |     result.issues.push({
131 |       nodeName: node.name,
132 |       nodeType: node.type,
133 |       issue: "Empty children array (should be removed)",
134 |       severity: "warning",
135 |     });
136 |   }
137 | 
138 |   // 3. VECTOR/ELLIPSE without exportInfo
139 |   if ((node.type === "VECTOR" || node.type === "ELLIPSE") && !node.exportInfo) {
140 |     result.issues.push({
141 |       nodeName: node.name,
142 |       nodeType: node.type,
143 |       issue: `${node.type} without exportInfo (image not exported)`,
144 |       severity: "warning",
145 |     });
146 |   }
147 | 
148 |   // Recurse into children
149 |   if (node.children) {
150 |     const currentLayout =
151 |       display || (node.cssStyles?.position === "absolute" ? "absolute" : undefined);
152 |     for (const child of node.children) {
153 |       analyzeNode(child, result, currentLayout);
154 |     }
155 |   }
156 | }
157 | 
158 | // Helper: Analyze a parsed result
159 | function analyzeOutput(result: ReturnType<typeof parseFigmaResponse>): QualityAnalysis {
160 |   const analysis: QualityAnalysis = {
161 |     totalNodes: 0,
162 |     nodesByType: {},
163 |     layoutStats: { flex: 0, grid: 0, absolute: 0, none: 0 },
164 |     cssPropertyUsage: {},
165 |     redundantPatterns: [],
166 |     emptyOrDefaultValues: [],
167 |     issues: [],
168 |   };
169 | 
170 |   for (const node of result.nodes) {
171 |     analyzeNode(node, analysis);
172 |   }
173 | 
174 |   return analysis;
175 | }
176 | 
177 | // Helper: Load and parse fixture
178 | function loadAndParse(name: string): ReturnType<typeof parseFigmaResponse> {
179 |   const filePath = path.join(fixturesDir, `${name}.json`);
180 |   const rawData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
181 |   return parseFigmaResponse(rawData);
182 | }
183 | 
184 | describe("Output Quality Validation", () => {
185 |   TEST_FILES.forEach(({ name, desc }) => {
186 |     describe(`${name} (${desc})`, () => {
187 |       let result: ReturnType<typeof parseFigmaResponse>;
188 |       let analysis: QualityAnalysis;
189 | 
190 |       beforeAll(() => {
191 |         const filePath = path.join(fixturesDir, `${name}.json`);
192 |         if (!fs.existsSync(filePath)) {
193 |           throw new Error(`Test fixture not found: ${name}.json`);
194 |         }
195 | 
196 |         result = loadAndParse(name);
197 |         analysis = analyzeOutput(result);
198 |       });
199 | 
200 |       describe("Node Structure", () => {
201 |         it("should have non-zero node count", () => {
202 |           expect(analysis.totalNodes).toBeGreaterThan(0);
203 |         });
204 | 
205 |         it("should have diverse node types", () => {
206 |           const typeCount = Object.keys(analysis.nodesByType).length;
207 |           expect(typeCount).toBeGreaterThan(1);
208 |         });
209 | 
210 |         it("should have reasonable node type distribution", () => {
211 |           // No single type should dominate excessively (>90%)
212 |           const maxTypeCount = Math.max(...Object.values(analysis.nodesByType));
213 |           const dominanceRatio = maxTypeCount / analysis.totalNodes;
214 |           expect(dominanceRatio).toBeLessThan(0.9);
215 |         });
216 |       });
217 | 
218 |       describe("Layout Quality", () => {
219 |         it("should use semantic layouts (flex/grid)", () => {
220 |           const semanticLayouts = analysis.layoutStats.flex + analysis.layoutStats.grid;
221 |           expect(semanticLayouts).toBeGreaterThan(0);
222 |         });
223 | 
224 |         it("should have reasonable absolute positioning ratio", () => {
225 |           const absoluteRatio = analysis.layoutStats.absolute / analysis.totalNodes;
226 |           // Warning if >80% absolute (but not a hard failure for all fixtures)
227 |           expect(absoluteRatio).toBeLessThan(0.95);
228 |         });
229 |       });
230 | 
231 |       describe("CSS Property Quality", () => {
232 |         it("should have essential CSS properties", () => {
233 |           // Width and height should be commonly used
234 |           const hasWidth = (analysis.cssPropertyUsage["width"] || 0) > 0;
235 |           const hasHeight = (analysis.cssPropertyUsage["height"] || 0) > 0;
236 |           expect(hasWidth || hasHeight).toBe(true);
237 |         });
238 | 
239 |         it("should not have excessive empty or default values", () => {
240 |           // Empty values should be less than 50% of total nodes
241 |           // Note: Some default values like "0px" may be intentional for clarity
242 |           const emptyRatio = analysis.emptyOrDefaultValues.length / analysis.totalNodes;
243 |           expect(emptyRatio).toBeLessThan(0.5);
244 |         });
245 | 
246 |         it("should have consistent property usage", () => {
247 |           // If display is used, it should be meaningful
248 |           const displayCount = analysis.cssPropertyUsage["display"] || 0;
249 |           if (displayCount > 0) {
250 |             // Display should be on containers, not every node
251 |             expect(displayCount).toBeLessThan(analysis.totalNodes);
252 |           }
253 |         });
254 |       });
255 | 
256 |       describe("Redundancy Check", () => {
257 |         it("should minimize position:absolute inside flex/grid children", () => {
258 |           const absoluteInLayout = analysis.redundantPatterns.filter(
259 |             (p) => p.pattern === "absolute-in-layout",
260 |           );
261 |           // Allow some absolute positioning for:
262 |           // - Overlapping elements that need stacking
263 |           // - Non-homogeneous elements in grid containers (e.g., tabs, dividers)
264 |           // These are intentionally kept absolute to preserve their original position
265 |           const ratio = absoluteInLayout.length / analysis.totalNodes;
266 |           expect(ratio).toBeLessThan(0.1); // Allow up to 10%
267 |         });
268 | 
269 |         it("should not have conflicting width and flex properties", () => {
270 |           const widthWithFlex = analysis.redundantPatterns.filter(
271 |             (p) => p.pattern === "width-with-flex",
272 |           );
273 |           // Warning level - not necessarily wrong but worth noting
274 |           // Allow up to 5% of nodes to have this pattern
275 |           const ratio = widthWithFlex.length / analysis.totalNodes;
276 |           expect(ratio).toBeLessThan(0.05);
277 |         });
278 |       });
279 | 
280 |       describe("Quality Issues", () => {
281 |         it("should not have TEXT nodes with layout properties", () => {
282 |           const textWithLayout = analysis.issues.filter(
283 |             (i) => i.nodeType === "TEXT" && i.issue.includes("layout"),
284 |           );
285 |           expect(textWithLayout.length).toBe(0);
286 |         });
287 | 
288 |         it("should not have empty children arrays", () => {
289 |           const emptyChildren = analysis.issues.filter((i) => i.issue.includes("Empty children"));
290 |           expect(emptyChildren.length).toBe(0);
291 |         });
292 | 
293 |         it("should have exportInfo for vector graphics", () => {
294 |           const vectorsWithoutExport = analysis.issues.filter((i) =>
295 |             i.issue.includes("without exportInfo"),
296 |           );
297 |           // Allow some vectors without export (decorative elements)
298 |           const vectorCount =
299 |             (analysis.nodesByType["VECTOR"] || 0) + (analysis.nodesByType["ELLIPSE"] || 0);
300 |           if (vectorCount > 0) {
301 |             const missingExportRatio = vectorsWithoutExport.length / vectorCount;
302 |             expect(missingExportRatio).toBeLessThan(0.5);
303 |           }
304 |         });
305 |       });
306 | 
307 |       describe("Output Statistics", () => {
308 |         it("should produce consistent layout statistics", () => {
309 |           // Snapshot the statistics for regression detection
310 |           expect({
311 |             totalNodes: analysis.totalNodes,
312 |             flexCount: analysis.layoutStats.flex,
313 |             gridCount: analysis.layoutStats.grid,
314 |             absoluteCount: analysis.layoutStats.absolute,
315 |             issueCount: analysis.issues.length,
316 |             redundantCount: analysis.redundantPatterns.length,
317 |           }).toMatchSnapshot();
318 |         });
319 |       });
320 |     });
321 |   });
322 | 
323 |   describe("Cross-fixture Consistency", () => {
324 |     let analyses: Map<string, QualityAnalysis>;
325 | 
326 |     beforeAll(() => {
327 |       analyses = new Map();
328 |       TEST_FILES.forEach(({ name }) => {
329 |         const filePath = path.join(fixturesDir, `${name}.json`);
330 |         if (fs.existsSync(filePath)) {
331 |           const result = loadAndParse(name);
332 |           analyses.set(name, analyzeOutput(result));
333 |         }
334 |       });
335 |     });
336 | 
337 |     it("should use consistent CSS properties across fixtures", () => {
338 |       const allProperties = new Set<string>();
339 |       analyses.forEach((analysis) => {
340 |         Object.keys(analysis.cssPropertyUsage).forEach((prop) => {
341 |           allProperties.add(prop);
342 |         });
343 |       });
344 | 
345 |       // Essential properties should appear in all fixtures
346 |       const essentialProps = ["width", "height"];
347 |       essentialProps.forEach((prop) => {
348 |         let count = 0;
349 |         analyses.forEach((analysis) => {
350 |           if (analysis.cssPropertyUsage[prop]) count++;
351 |         });
352 |         expect(count).toBeGreaterThan(0);
353 |       });
354 |     });
355 | 
356 |     it("should have similar quality metrics across fixtures", () => {
357 |       const qualityScores: number[] = [];
358 | 
359 |       analyses.forEach((analysis) => {
360 |         // Quality score: higher is better
361 |         const semanticRatio =
362 |           (analysis.layoutStats.flex + analysis.layoutStats.grid) / analysis.totalNodes;
363 |         const issueRatio = analysis.issues.length / analysis.totalNodes;
364 |         const score = semanticRatio * 100 - issueRatio * 50;
365 |         qualityScores.push(score);
366 |       });
367 | 
368 |       // All fixtures should have non-negative quality scores
369 |       qualityScores.forEach((score) => {
370 |         expect(score).toBeGreaterThanOrEqual(-10);
371 |       });
372 |     });
373 |   });
374 | });
375 | 
```

--------------------------------------------------------------------------------
/src/algorithms/layout/spatial.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Spatial Projection Analyzer
  3 |  *
  4 |  * Analyzes spatial relationships between nodes using 2D projection techniques.
  5 |  * Used to detect layout patterns, grouping, and containment relationships.
  6 |  *
  7 |  * @module algorithms/layout/spatial
  8 |  */
  9 | 
 10 | import type { SimplifiedNode } from "~/types/index.js";
 11 | 
 12 | // ==================== Type Definitions ====================
 13 | 
 14 | /**
 15 |  * Rectangle representing a node's bounding box
 16 |  */
 17 | export interface Rect {
 18 |   left: number;
 19 |   top: number;
 20 |   width: number;
 21 |   height: number;
 22 | }
 23 | 
 24 | /**
 25 |  * Spatial relationship between two nodes
 26 |  */
 27 | export enum NodeRelationship {
 28 |   /** One node completely contains another */
 29 |   CONTAINS = "contains",
 30 |   /** Two nodes partially overlap */
 31 |   INTERSECTS = "intersects",
 32 |   /** Two nodes do not overlap */
 33 |   SEPARATE = "separate",
 34 | }
 35 | 
 36 | /**
 37 |  * Projection line for spatial division
 38 |  */
 39 | export interface ProjectionLine {
 40 |   /** Position of the line */
 41 |   position: number;
 42 |   /** Direction: 'horizontal' or 'vertical' */
 43 |   direction: "horizontal" | "vertical";
 44 |   /** Indices of nodes intersecting this line */
 45 |   nodeIndices: number[];
 46 | }
 47 | 
 48 | // ==================== Helper Functions ====================
 49 | 
 50 | /**
 51 |  * Safely parse CSS numeric value
 52 |  * @param value - CSS value string
 53 |  * @param defaultValue - Default value if parsing fails
 54 |  */
 55 | function safeParseFloat(value: string | undefined | null, defaultValue: number = 0): number {
 56 |   if (!value) return defaultValue;
 57 |   const parsed = parseFloat(value);
 58 |   return isNaN(parsed) ? defaultValue : parsed;
 59 | }
 60 | 
 61 | /**
 62 |  * Get node position from absolute coordinates or CSS styles
 63 |  */
 64 | function getNodePosition(node: SimplifiedNode): { left: number; top: number } {
 65 |   let left = 0;
 66 |   let top = 0;
 67 | 
 68 |   // Prefer absolute coordinates
 69 |   if (typeof node._absoluteX === "number") {
 70 |     left = node._absoluteX;
 71 |   } else if (node.cssStyles?.left) {
 72 |     left = safeParseFloat(node.cssStyles.left as string);
 73 |   }
 74 | 
 75 |   if (typeof node._absoluteY === "number") {
 76 |     top = node._absoluteY;
 77 |   } else if (node.cssStyles?.top) {
 78 |     top = safeParseFloat(node.cssStyles.top as string);
 79 |   }
 80 | 
 81 |   return { left, top };
 82 | }
 83 | 
 84 | // ==================== Rectangle Utilities ====================
 85 | 
 86 | /**
 87 |  * Utility class for rectangle operations
 88 |  */
 89 | export class RectUtils {
 90 |   /**
 91 |    * Create Rect from SimplifiedNode
 92 |    */
 93 |   static fromNode(node: SimplifiedNode): Rect | null {
 94 |     if (!node.cssStyles || !node.cssStyles.width || !node.cssStyles.height) {
 95 |       return null;
 96 |     }
 97 | 
 98 |     const width = safeParseFloat(node.cssStyles.width as string, 0);
 99 |     const height = safeParseFloat(node.cssStyles.height as string, 0);
100 | 
101 |     // Invalid rectangle if dimensions are zero
102 |     if (width <= 0 || height <= 0) {
103 |       return null;
104 |     }
105 | 
106 |     const { left, top } = getNodePosition(node);
107 |     return { left, top, width, height };
108 |   }
109 | 
110 |   /**
111 |    * Check if rectangle A contains rectangle B
112 |    */
113 |   static contains(a: Rect, b: Rect): boolean {
114 |     return (
115 |       a.left <= b.left &&
116 |       a.top <= b.top &&
117 |       a.left + a.width >= b.left + b.width &&
118 |       a.top + a.height >= b.top + b.height
119 |     );
120 |   }
121 | 
122 |   /**
123 |    * Check if two rectangles intersect
124 |    */
125 |   static intersects(a: Rect, b: Rect): boolean {
126 |     return !(
127 |       a.left + a.width <= b.left ||
128 |       b.left + b.width <= a.left ||
129 |       a.top + a.height <= b.top ||
130 |       b.top + b.height <= a.top
131 |     );
132 |   }
133 | 
134 |   /**
135 |    * Calculate intersection of two rectangles
136 |    */
137 |   static intersection(a: Rect, b: Rect): Rect | null {
138 |     if (!RectUtils.intersects(a, b)) {
139 |       return null;
140 |     }
141 | 
142 |     const left = Math.max(a.left, b.left);
143 |     const top = Math.max(a.top, b.top);
144 |     const right = Math.min(a.left + a.width, b.left + b.width);
145 |     const bottom = Math.min(a.top + a.height, b.top + b.height);
146 | 
147 |     return {
148 |       left,
149 |       top,
150 |       width: right - left,
151 |       height: bottom - top,
152 |     };
153 |   }
154 | 
155 |   /**
156 |    * Analyze spatial relationship between two rectangles
157 |    */
158 |   static analyzeRelationship(a: Rect, b: Rect): NodeRelationship {
159 |     if (RectUtils.contains(a, b)) {
160 |       return NodeRelationship.CONTAINS;
161 |     } else if (RectUtils.contains(b, a)) {
162 |       return NodeRelationship.CONTAINS;
163 |     } else if (RectUtils.intersects(a, b)) {
164 |       return NodeRelationship.INTERSECTS;
165 |     } else {
166 |       return NodeRelationship.SEPARATE;
167 |     }
168 |   }
169 | }
170 | 
171 | // ==================== Spatial Projection Analyzer ====================
172 | 
173 | /**
174 |  * 2D Spatial Projection Analyzer
175 |  *
176 |  * Uses projection lines to analyze spatial relationships
177 |  * and group nodes by rows and columns.
178 |  */
179 | export class SpatialProjectionAnalyzer {
180 |   /**
181 |    * Convert SimplifiedNode array to Rect array
182 |    */
183 |   static nodesToRects(nodes: SimplifiedNode[]): Rect[] {
184 |     return nodes
185 |       .map((node) => RectUtils.fromNode(node))
186 |       .filter((rect): rect is Rect => rect !== null);
187 |   }
188 | 
189 |   /**
190 |    * Generate horizontal projection lines (for row detection)
191 |    * @param rects - Rectangle array
192 |    * @param tolerance - Coordinate tolerance in pixels
193 |    */
194 |   static generateHorizontalProjectionLines(rects: Rect[], tolerance: number = 1): ProjectionLine[] {
195 |     if (rects.length === 0) return [];
196 | 
197 |     // Collect all top and bottom Y coordinates
198 |     const yCoordinates: number[] = [];
199 |     rects.forEach((rect) => {
200 |       yCoordinates.push(rect.top);
201 |       yCoordinates.push(rect.top + rect.height);
202 |     });
203 | 
204 |     // Sort and filter nearby coordinates
205 |     yCoordinates.sort((a, b) => a - b);
206 |     const uniqueYCoordinates: number[] = [];
207 | 
208 |     for (let i = 0; i < yCoordinates.length; i++) {
209 |       if (i === 0 || Math.abs(yCoordinates[i] - yCoordinates[i - 1]) > tolerance) {
210 |         uniqueYCoordinates.push(yCoordinates[i]);
211 |       }
212 |     }
213 | 
214 |     // Create projection line for each Y coordinate
215 |     return uniqueYCoordinates.map((y) => {
216 |       const line: ProjectionLine = {
217 |         position: y,
218 |         direction: "horizontal",
219 |         nodeIndices: [],
220 |       };
221 | 
222 |       // Find all nodes intersecting this line
223 |       for (let i = 0; i < rects.length; i++) {
224 |         const rect = rects[i];
225 |         if (y >= rect.top && y <= rect.top + rect.height) {
226 |           line.nodeIndices.push(i);
227 |         }
228 |       }
229 | 
230 |       return line;
231 |     });
232 |   }
233 | 
234 |   /**
235 |    * Generate vertical projection lines (for column detection)
236 |    * @param rects - Rectangle array
237 |    * @param tolerance - Coordinate tolerance in pixels
238 |    */
239 |   static generateVerticalProjectionLines(rects: Rect[], tolerance: number = 1): ProjectionLine[] {
240 |     if (rects.length === 0) return [];
241 | 
242 |     // Collect all left and right X coordinates
243 |     const xCoordinates: number[] = [];
244 |     rects.forEach((rect) => {
245 |       xCoordinates.push(rect.left);
246 |       xCoordinates.push(rect.left + rect.width);
247 |     });
248 | 
249 |     // Sort and filter nearby coordinates
250 |     xCoordinates.sort((a, b) => a - b);
251 |     const uniqueXCoordinates: number[] = [];
252 | 
253 |     for (let i = 0; i < xCoordinates.length; i++) {
254 |       if (i === 0 || Math.abs(xCoordinates[i] - xCoordinates[i - 1]) > tolerance) {
255 |         uniqueXCoordinates.push(xCoordinates[i]);
256 |       }
257 |     }
258 | 
259 |     // Create projection line for each X coordinate
260 |     return uniqueXCoordinates.map((x) => {
261 |       const line: ProjectionLine = {
262 |         position: x,
263 |         direction: "vertical",
264 |         nodeIndices: [],
265 |       };
266 | 
267 |       // Find all nodes intersecting this line
268 |       for (let i = 0; i < rects.length; i++) {
269 |         const rect = rects[i];
270 |         if (x >= rect.left && x <= rect.left + rect.width) {
271 |           line.nodeIndices.push(i);
272 |         }
273 |       }
274 | 
275 |       return line;
276 |     });
277 |   }
278 | 
279 |   /**
280 |    * Group nodes by rows
281 |    * @param nodes - Node array
282 |    * @param tolerance - Tolerance in pixels
283 |    */
284 |   static groupNodesByRows(nodes: SimplifiedNode[], tolerance: number = 1): SimplifiedNode[][] {
285 |     const rects = this.nodesToRects(nodes);
286 |     if (rects.length === 0) return [nodes];
287 | 
288 |     const projectionLines = this.generateHorizontalProjectionLines(rects, tolerance);
289 |     const rows: SimplifiedNode[][] = [];
290 | 
291 |     for (let i = 0; i < projectionLines.length - 1; i++) {
292 |       const currentLine = projectionLines[i];
293 |       const nextLine = projectionLines[i + 1];
294 | 
295 |       const nodesBetweenLines = new Set<number>();
296 | 
297 |       // Find nodes completely between these two lines
298 |       for (let j = 0; j < rects.length; j++) {
299 |         const rect = rects[j];
300 |         if (rect.top >= currentLine.position && rect.top + rect.height <= nextLine.position) {
301 |           nodesBetweenLines.add(j);
302 |         }
303 |       }
304 | 
305 |       if (nodesBetweenLines.size > 0) {
306 |         // Sort nodes left to right
307 |         const rowNodes = Array.from(nodesBetweenLines)
308 |           .map((index) => nodes[index])
309 |           .sort((a, b) => {
310 |             const { left: aLeft } = getNodePosition(a);
311 |             const { left: bLeft } = getNodePosition(b);
312 |             return aLeft - bLeft;
313 |           });
314 | 
315 |         rows.push(rowNodes);
316 |       }
317 |     }
318 | 
319 |     // If no rows found, treat all nodes as one row
320 |     if (rows.length === 0) {
321 |       rows.push([...nodes]);
322 |     }
323 | 
324 |     return rows;
325 |   }
326 | 
327 |   /**
328 |    * Group row nodes by columns
329 |    * @param rowNodes - Nodes in a row
330 |    * @param tolerance - Tolerance in pixels
331 |    */
332 |   static groupRowNodesByColumns(
333 |     rowNodes: SimplifiedNode[],
334 |     tolerance: number = 1,
335 |   ): SimplifiedNode[][] {
336 |     const rects = this.nodesToRects(rowNodes);
337 |     if (rects.length === 0) return [rowNodes];
338 | 
339 |     const projectionLines = this.generateVerticalProjectionLines(rects, tolerance);
340 |     const columns: SimplifiedNode[][] = [];
341 | 
342 |     for (let i = 0; i < projectionLines.length - 1; i++) {
343 |       const currentLine = projectionLines[i];
344 |       const nextLine = projectionLines[i + 1];
345 | 
346 |       const nodesBetweenLines = new Set<number>();
347 | 
348 |       // Find nodes completely between these two lines
349 |       for (let j = 0; j < rects.length; j++) {
350 |         const rect = rects[j];
351 |         if (rect.left >= currentLine.position && rect.left + rect.width <= nextLine.position) {
352 |           nodesBetweenLines.add(j);
353 |         }
354 |       }
355 | 
356 |       if (nodesBetweenLines.size > 0) {
357 |         const colNodes = Array.from(nodesBetweenLines).map((index) => rowNodes[index]);
358 |         columns.push(colNodes);
359 |       }
360 |     }
361 | 
362 |     // If no columns found, treat all nodes as one column
363 |     if (columns.length === 0) {
364 |       columns.push([...rowNodes]);
365 |     }
366 | 
367 |     return columns;
368 |   }
369 | 
370 |   /**
371 |    * Process node spatial relationships and build containment hierarchy
372 |    * @param nodes - Node array
373 |    */
374 |   static processNodeRelationships(nodes: SimplifiedNode[]): SimplifiedNode[] {
375 |     if (nodes.length <= 1) return [...nodes];
376 | 
377 |     const rects = this.nodesToRects(nodes);
378 |     if (rects.length !== nodes.length) {
379 |       return nodes; // Cannot process all nodes, return as-is
380 |     }
381 | 
382 |     // Find all containment relationships
383 |     const containsRelations: [number, number][] = [];
384 |     for (let i = 0; i < rects.length; i++) {
385 |       for (let j = 0; j < rects.length; j++) {
386 |         if (i !== j && RectUtils.contains(rects[i], rects[j])) {
387 |           containsRelations.push([i, j]); // Node i contains node j
388 |         }
389 |       }
390 |     }
391 | 
392 |     // Build containment graph
393 |     const childrenMap = new Map<number, Set<number>>();
394 |     const parentMap = new Map<number, number | null>();
395 | 
396 |     // Initialize all nodes without parents
397 |     for (let i = 0; i < nodes.length; i++) {
398 |       parentMap.set(i, null);
399 |       childrenMap.set(i, new Set<number>());
400 |     }
401 | 
402 |     // Process containment relationships
403 |     for (const [parent, child] of containsRelations) {
404 |       childrenMap.get(parent)?.add(child);
405 |       parentMap.set(child, parent);
406 |     }
407 | 
408 |     // Fix multi-level containment - ensure each node has only its direct parent
409 |     for (const [child, parent] of parentMap.entries()) {
410 |       if (parent === null) continue;
411 | 
412 |       let currentParent = parent;
413 |       let grandParent = parentMap.get(currentParent);
414 | 
415 |       while (grandParent !== null && grandParent !== undefined) {
416 |         // If grandparent also directly contains child, remove parent-child direct relation
417 |         if (childrenMap.get(grandParent)?.has(child)) {
418 |           childrenMap.get(currentParent)?.delete(child);
419 |         }
420 | 
421 |         currentParent = grandParent;
422 |         grandParent = parentMap.get(currentParent);
423 |       }
424 |     }
425 | 
426 |     // Build new node tree structure
427 |     const rootIndices = Array.from(parentMap.entries())
428 |       .filter(([_, parent]) => parent === null)
429 |       .map(([index]) => index);
430 | 
431 |     const result: SimplifiedNode[] = [];
432 | 
433 |     // Recursively build node tree
434 |     const buildNodeTree = (nodeIndex: number): SimplifiedNode => {
435 |       const node = { ...nodes[nodeIndex] };
436 |       const childIndices = Array.from(childrenMap.get(nodeIndex) || []);
437 | 
438 |       if (childIndices.length > 0) {
439 |         node.children = childIndices.map(buildNodeTree);
440 |       }
441 | 
442 |       return node;
443 |     };
444 | 
445 |     // Build from all root nodes
446 |     for (const rootIndex of rootIndices) {
447 |       result.push(buildNodeTree(rootIndex));
448 |     }
449 | 
450 |     return result;
451 |   }
452 | }
453 | 
```

--------------------------------------------------------------------------------
/src/services/figma.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import fs from "fs";
  2 | import path from "path";
  3 | import { parseFigmaResponse } from "~/core/parser.js";
  4 | import type { SimplifiedDesign } from "~/types/index.js";
  5 | import { cacheManager } from "./cache.js";
  6 | import type {
  7 |   GetImagesResponse,
  8 |   GetFileResponse,
  9 |   GetFileNodesResponse,
 10 |   GetImageFillsResponse,
 11 | } from "@figma/rest-api-spec";
 12 | import { Logger } from "~/server.js";
 13 | import type {
 14 |   FigmaError,
 15 |   RateLimitInfo,
 16 |   FetchImageParams,
 17 |   FetchImageFillParams,
 18 | } from "~/types/index.js";
 19 | 
 20 | // Re-export types for backward compatibility
 21 | export type { FigmaError, RateLimitInfo, FetchImageParams, FetchImageFillParams };
 22 | 
 23 | // ==================== Internal Types ====================
 24 | 
 25 | /**
 26 |  * API Response Result (internal use only)
 27 |  */
 28 | interface ApiResponse<T> {
 29 |   data: T;
 30 |   rateLimitInfo: RateLimitInfo;
 31 | }
 32 | 
 33 | // ==================== Utility Functions ====================
 34 | 
 35 | /**
 36 |  * Validate fileKey format
 37 |  */
 38 | function validateFileKey(fileKey: string): void {
 39 |   if (!fileKey || typeof fileKey !== "string") {
 40 |     throw createFigmaError(400, "fileKey is required");
 41 |   }
 42 |   // Figma fileKey is typically alphanumeric
 43 |   if (!/^[a-zA-Z0-9_-]+$/.test(fileKey)) {
 44 |     throw createFigmaError(400, `Invalid fileKey format: ${fileKey}`);
 45 |   }
 46 | }
 47 | 
 48 | /**
 49 |  * Validate nodeId format
 50 |  */
 51 | function validateNodeId(nodeId: string): void {
 52 |   if (!nodeId || typeof nodeId !== "string") {
 53 |     throw createFigmaError(400, "nodeId is required");
 54 |   }
 55 |   // Figma nodeId format is typically number:number or number-number
 56 |   if (!/^[\d:_-]+$/.test(nodeId)) {
 57 |     throw createFigmaError(400, `Invalid nodeId format: ${nodeId}`);
 58 |   }
 59 | }
 60 | 
 61 | /**
 62 |  * Validate depth parameter
 63 |  */
 64 | function validateDepth(depth?: number): void {
 65 |   if (depth !== undefined) {
 66 |     if (typeof depth !== "number" || depth < 1 || depth > 100) {
 67 |       throw createFigmaError(400, "depth must be a number between 1 and 100");
 68 |     }
 69 |   }
 70 | }
 71 | 
 72 | /**
 73 |  * Validate local path security
 74 |  */
 75 | function validateLocalPath(localPath: string, fileName: string): string {
 76 |   const normalizedPath = path.resolve(localPath, fileName);
 77 |   const resolvedLocalPath = path.resolve(localPath);
 78 | 
 79 |   if (!normalizedPath.startsWith(resolvedLocalPath)) {
 80 |     throw createFigmaError(400, "Invalid file path: path traversal detected");
 81 |   }
 82 | 
 83 |   return normalizedPath;
 84 | }
 85 | 
 86 | /**
 87 |  * Create Figma error
 88 |  */
 89 | function createFigmaError(
 90 |   status: number,
 91 |   message: string,
 92 |   rateLimitInfo?: RateLimitInfo,
 93 | ): FigmaError {
 94 |   return {
 95 |     status,
 96 |     err: message,
 97 |     rateLimitInfo,
 98 |   };
 99 | }
100 | 
101 | /**
102 |  * Extract Rate Limit information from response headers
103 |  */
104 | function extractRateLimitInfo(headers: Headers): RateLimitInfo {
105 |   return {
106 |     remaining: headers.has("x-rate-limit-remaining")
107 |       ? parseInt(headers.get("x-rate-limit-remaining")!, 10)
108 |       : null,
109 |     resetAfter: headers.has("x-rate-limit-reset")
110 |       ? parseInt(headers.get("x-rate-limit-reset")!, 10)
111 |       : null,
112 |     retryAfter: headers.has("retry-after") ? parseInt(headers.get("retry-after")!, 10) : null,
113 |   };
114 | }
115 | 
116 | /**
117 |  * Format Rate Limit error message
118 |  */
119 | function formatRateLimitError(rateLimitInfo: RateLimitInfo): string {
120 |   const parts: string[] = ["Figma API rate limit exceeded (429 Too Many Requests)."];
121 | 
122 |   if (rateLimitInfo.retryAfter !== null) {
123 |     const minutes = Math.ceil(rateLimitInfo.retryAfter / 60);
124 |     const hours = Math.ceil(rateLimitInfo.retryAfter / 3600);
125 |     const days = Math.ceil(rateLimitInfo.retryAfter / 86400);
126 | 
127 |     if (days > 1) {
128 |       parts.push(`Please retry after ${days} days.`);
129 |     } else if (hours > 1) {
130 |       parts.push(`Please retry after ${hours} hours.`);
131 |     } else {
132 |       parts.push(`Please retry after ${minutes} minutes.`);
133 |     }
134 |   }
135 | 
136 |   parts.push(
137 |     "\nThis is likely due to Figma's November 2025 rate limit update.",
138 |     "Starter plan: 6 requests/month. Professional plan: 10 requests/minute.",
139 |     "\nSuggestions:",
140 |     "1. Check if the design file belongs to a Starter plan workspace",
141 |     "2. Duplicate the file to your own Professional workspace",
142 |     "3. Wait for the rate limit to reset",
143 |   );
144 | 
145 |   return parts.join(" ");
146 | }
147 | 
148 | /**
149 |  * Download image to local filesystem
150 |  */
151 | async function downloadImage(
152 |   url: string,
153 |   localPath: string,
154 |   fileName: string,
155 |   fileKey: string,
156 |   nodeId: string,
157 |   format: string,
158 | ): Promise<string> {
159 |   // Validate path security
160 |   const fullPath = validateLocalPath(localPath, fileName);
161 | 
162 |   // Check image cache
163 |   const cachedPath = await cacheManager.hasImage(fileKey, nodeId, format);
164 |   if (cachedPath) {
165 |     // Copy from cache to target path
166 |     const copied = await cacheManager.copyImageFromCache(fileKey, nodeId, format, fullPath);
167 |     if (copied) {
168 |       Logger.log(`Image loaded from cache: ${fileName}`);
169 |       return fullPath;
170 |     }
171 |   }
172 | 
173 |   // Ensure directory exists
174 |   const dir = path.dirname(fullPath);
175 |   if (!fs.existsSync(dir)) {
176 |     fs.mkdirSync(dir, { recursive: true });
177 |   }
178 | 
179 |   // Download image
180 |   const response = await fetch(url, {
181 |     method: "GET",
182 |     signal: AbortSignal.timeout(30000), // 30 second timeout
183 |   });
184 | 
185 |   if (!response.ok) {
186 |     throw new Error(`Failed to download image: ${response.statusText}`);
187 |   }
188 | 
189 |   // Use arrayBuffer instead of streaming for better reliability
190 |   const buffer = await response.arrayBuffer();
191 |   await fs.promises.writeFile(fullPath, Buffer.from(buffer));
192 | 
193 |   // Cache image
194 |   await cacheManager.cacheImage(fullPath, fileKey, nodeId, format);
195 | 
196 |   return fullPath;
197 | }
198 | 
199 | // ==================== Logging Utilities ====================
200 | 
201 | /**
202 |  * Write development logs
203 |  */
204 | function writeLogs(name: string, value: unknown): void {
205 |   try {
206 |     if (process.env.NODE_ENV !== "development") return;
207 | 
208 |     const logsDir = "logs";
209 | 
210 |     try {
211 |       fs.accessSync(process.cwd(), fs.constants.W_OK);
212 |     } catch {
213 |       return;
214 |     }
215 | 
216 |     if (!fs.existsSync(logsDir)) {
217 |       fs.mkdirSync(logsDir);
218 |     }
219 |     fs.writeFileSync(`${logsDir}/${name}`, JSON.stringify(value, null, 2));
220 |   } catch {
221 |     // Ignore log write errors
222 |   }
223 | }
224 | 
225 | // ==================== Figma Service Class ====================
226 | 
227 | /**
228 |  * Figma API Service
229 |  */
230 | export class FigmaService {
231 |   private readonly apiKey: string;
232 |   private readonly baseUrl = "https://api.figma.com/v1";
233 | 
234 |   /** Most recent Rate Limit information */
235 |   private lastRateLimitInfo: RateLimitInfo | null = null;
236 | 
237 |   constructor(apiKey: string) {
238 |     if (!apiKey || typeof apiKey !== "string") {
239 |       throw new Error("Figma API key is required");
240 |     }
241 |     this.apiKey = apiKey;
242 |   }
243 | 
244 |   /**
245 |    * Get most recent Rate Limit information
246 |    */
247 |   getRateLimitInfo(): RateLimitInfo | null {
248 |     return this.lastRateLimitInfo;
249 |   }
250 | 
251 |   /**
252 |    * Make API request
253 |    */
254 |   private async request<T>(endpoint: string): Promise<ApiResponse<T>> {
255 |     if (typeof fetch !== "function") {
256 |       throw new Error(
257 |         "The MCP server requires Node.js 18+ with fetch support.\n" +
258 |           "Please upgrade your Node.js version to continue.",
259 |       );
260 |     }
261 | 
262 |     Logger.log(`Calling ${this.baseUrl}${endpoint}`);
263 | 
264 |     const response = await fetch(`${this.baseUrl}${endpoint}`, {
265 |       headers: {
266 |         "X-Figma-Token": this.apiKey,
267 |       },
268 |     });
269 | 
270 |     // Extract Rate Limit information
271 |     const rateLimitInfo = extractRateLimitInfo(response.headers);
272 |     this.lastRateLimitInfo = rateLimitInfo;
273 | 
274 |     // Handle error responses
275 |     if (!response.ok) {
276 |       const status = response.status;
277 |       let errorMessage = response.statusText || "Unknown error";
278 | 
279 |       // Special handling for 429 errors
280 |       if (status === 429) {
281 |         errorMessage = formatRateLimitError(rateLimitInfo);
282 |       } else if (status === 403) {
283 |         errorMessage = "Access denied. Please check your Figma API key and file permissions.";
284 |       } else if (status === 404) {
285 |         errorMessage = "File or node not found. Please verify the fileKey and nodeId are correct.";
286 |       }
287 | 
288 |       throw createFigmaError(status, errorMessage, rateLimitInfo);
289 |     }
290 | 
291 |     const data = (await response.json()) as T;
292 |     return { data, rateLimitInfo };
293 |   }
294 | 
295 |   /**
296 |    * Get image fill URLs and download
297 |    */
298 |   async getImageFills(
299 |     fileKey: string,
300 |     nodes: FetchImageFillParams[],
301 |     localPath: string,
302 |   ): Promise<string[]> {
303 |     if (nodes.length === 0) return [];
304 | 
305 |     // Validate parameters
306 |     validateFileKey(fileKey);
307 |     nodes.forEach((node) => {
308 |       validateNodeId(node.nodeId);
309 |     });
310 | 
311 |     const endpoint = `/files/${fileKey}/images`;
312 |     const { data } = await this.request<GetImageFillsResponse>(endpoint);
313 |     const { images = {} } = data.meta;
314 | 
315 |     const downloads = nodes.map(async ({ imageRef, fileName, nodeId }) => {
316 |       const imageUrl = images[imageRef];
317 |       if (!imageUrl) {
318 |         Logger.log(`Image not found for ref: ${imageRef}`);
319 |         return "";
320 |       }
321 | 
322 |       try {
323 |         const format = fileName.toLowerCase().endsWith(".svg") ? "svg" : "png";
324 |         return await downloadImage(imageUrl, localPath, fileName, fileKey, nodeId, format);
325 |       } catch (error) {
326 |         Logger.error(`Failed to download image ${fileName}:`, error);
327 |         return "";
328 |       }
329 |     });
330 | 
331 |     return Promise.all(downloads);
332 |   }
333 | 
334 |   /**
335 |    * Render nodes as images and download
336 |    */
337 |   async getImages(
338 |     fileKey: string,
339 |     nodes: FetchImageParams[],
340 |     localPath: string,
341 |   ): Promise<string[]> {
342 |     if (nodes.length === 0) return [];
343 | 
344 |     // Validate parameters
345 |     validateFileKey(fileKey);
346 |     nodes.forEach((node) => validateNodeId(node.nodeId));
347 | 
348 |     // Categorize PNG and SVG nodes
349 |     const pngNodes = nodes.filter(({ fileType }) => fileType === "png");
350 |     const svgNodes = nodes.filter(({ fileType }) => fileType === "svg");
351 | 
352 |     // Get image URLs (sequential execution to reduce Rate Limit risk)
353 |     const imageUrls: Record<string, string> = {};
354 | 
355 |     if (pngNodes.length > 0) {
356 |       const pngIds = pngNodes.map(({ nodeId }) => nodeId).join(",");
357 |       const { data } = await this.request<GetImagesResponse>(
358 |         `/images/${fileKey}?ids=${pngIds}&scale=2&format=png`,
359 |       );
360 |       Object.assign(imageUrls, data.images || {});
361 |     }
362 | 
363 |     if (svgNodes.length > 0) {
364 |       const svgIds = svgNodes.map(({ nodeId }) => nodeId).join(",");
365 |       const { data } = await this.request<GetImagesResponse>(
366 |         `/images/${fileKey}?ids=${svgIds}&scale=2&format=svg`,
367 |       );
368 |       Object.assign(imageUrls, data.images || {});
369 |     }
370 | 
371 |     // Download images
372 |     const downloads = nodes.map(async ({ nodeId, fileName, fileType }) => {
373 |       const imageUrl = imageUrls[nodeId];
374 |       if (!imageUrl) {
375 |         Logger.log(`Image URL not found for node: ${nodeId}`);
376 |         return "";
377 |       }
378 | 
379 |       try {
380 |         return await downloadImage(imageUrl, localPath, fileName, fileKey, nodeId, fileType);
381 |       } catch (error) {
382 |         Logger.error(`Failed to download image ${fileName}:`, error);
383 |         return "";
384 |       }
385 |     });
386 | 
387 |     return Promise.all(downloads);
388 |   }
389 | 
390 |   /**
391 |    * Get entire Figma file
392 |    */
393 |   async getFile(fileKey: string, depth?: number): Promise<SimplifiedDesign> {
394 |     // Validate parameters
395 |     validateFileKey(fileKey);
396 |     validateDepth(depth);
397 | 
398 |     // Try to get from cache
399 |     const cached = await cacheManager.getNodeData<SimplifiedDesign>(fileKey, undefined, depth);
400 |     if (cached) {
401 |       Logger.log(`File loaded from cache: ${fileKey}`);
402 |       return cached;
403 |     }
404 | 
405 |     try {
406 |       const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`;
407 |       Logger.log(`Retrieving Figma file: ${fileKey} (depth: ${depth ?? "default"})`);
408 | 
409 |       const { data: response } = await this.request<GetFileResponse>(endpoint);
410 |       Logger.log("Got response");
411 | 
412 |       const simplifiedResponse = parseFigmaResponse(response);
413 | 
414 |       // Write development logs
415 |       writeLogs("figma-raw.json", response);
416 |       writeLogs("figma-simplified.json", simplifiedResponse);
417 | 
418 |       // Write to cache
419 |       await cacheManager.setNodeData(simplifiedResponse, fileKey, undefined, depth);
420 | 
421 |       return simplifiedResponse;
422 |     } catch (error) {
423 |       // Re-throw Figma errors to preserve details
424 |       if ((error as FigmaError).status) {
425 |         throw error;
426 |       }
427 |       Logger.error("Failed to get file:", error);
428 |       throw error;
429 |     }
430 |   }
431 | 
432 |   /**
433 |    * Get specific node
434 |    */
435 |   async getNode(fileKey: string, nodeId: string, depth?: number): Promise<SimplifiedDesign> {
436 |     // Validate parameters
437 |     validateFileKey(fileKey);
438 |     validateNodeId(nodeId);
439 |     validateDepth(depth);
440 | 
441 |     // Try to get from cache
442 |     const cached = await cacheManager.getNodeData<SimplifiedDesign>(fileKey, nodeId, depth);
443 |     if (cached) {
444 |       Logger.log(`Node loaded from cache: ${fileKey}/${nodeId}`);
445 |       return cached;
446 |     }
447 | 
448 |     const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`;
449 |     const { data: response } = await this.request<GetFileNodesResponse>(endpoint);
450 | 
451 |     Logger.log("Got response from getNode, now parsing.");
452 |     writeLogs("figma-raw.json", response);
453 | 
454 |     const simplifiedResponse = parseFigmaResponse(response);
455 |     writeLogs("figma-simplified.json", simplifiedResponse);
456 | 
457 |     // Write to cache
458 |     await cacheManager.setNodeData(simplifiedResponse, fileKey, nodeId, depth);
459 | 
460 |     return simplifiedResponse;
461 |   }
462 | }
463 | 
```

--------------------------------------------------------------------------------
/src/services/cache/disk-cache.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Disk Cache
  3 |  *
  4 |  * Persistent file-based cache for Figma data and images.
  5 |  * Acts as L2 cache layer after in-memory LRU cache.
  6 |  *
  7 |  * @module services/cache/disk-cache
  8 |  */
  9 | 
 10 | import fs from "fs";
 11 | import path from "path";
 12 | import crypto from "crypto";
 13 | import os from "os";
 14 | import type { DiskCacheConfig, CacheEntryMeta, DiskCacheStats } from "./types.js";
 15 | 
 16 | /**
 17 |  * Default disk cache configuration
 18 |  */
 19 | const DEFAULT_CONFIG: DiskCacheConfig = {
 20 |   cacheDir: path.join(os.homedir(), ".figma-mcp-cache"),
 21 |   maxSize: 500 * 1024 * 1024, // 500MB
 22 |   ttl: 24 * 60 * 60 * 1000, // 24 hours
 23 | };
 24 | 
 25 | /**
 26 |  * Disk-based persistent cache
 27 |  */
 28 | export class DiskCache {
 29 |   private config: DiskCacheConfig;
 30 |   private dataDir: string;
 31 |   private imageDir: string;
 32 |   private metadataDir: string;
 33 |   private stats: { hits: number; misses: number };
 34 | 
 35 |   constructor(config: Partial<DiskCacheConfig> = {}) {
 36 |     this.config = { ...DEFAULT_CONFIG, ...config };
 37 |     this.dataDir = path.join(this.config.cacheDir, "data");
 38 |     this.imageDir = path.join(this.config.cacheDir, "images");
 39 |     this.metadataDir = path.join(this.config.cacheDir, "metadata");
 40 |     this.stats = { hits: 0, misses: 0 };
 41 | 
 42 |     this.ensureCacheDirectories();
 43 |   }
 44 | 
 45 |   /**
 46 |    * Ensure cache directories exist
 47 |    */
 48 |   private ensureCacheDirectories(): void {
 49 |     try {
 50 |       [this.config.cacheDir, this.dataDir, this.imageDir, this.metadataDir].forEach((dir) => {
 51 |         if (!fs.existsSync(dir)) {
 52 |           fs.mkdirSync(dir, { recursive: true });
 53 |         }
 54 |       });
 55 |     } catch (error) {
 56 |       console.warn("Failed to create cache directories:", error);
 57 |     }
 58 |   }
 59 | 
 60 |   /**
 61 |    * Generate cache key from components
 62 |    */
 63 |   static generateKey(fileKey: string, nodeId?: string, depth?: number): string {
 64 |     const keyParts = [fileKey];
 65 |     if (nodeId) keyParts.push(`node-${nodeId}`);
 66 |     if (depth !== undefined) keyParts.push(`depth-${depth}`);
 67 | 
 68 |     const keyString = keyParts.join("_");
 69 |     return crypto.createHash("md5").update(keyString).digest("hex");
 70 |   }
 71 | 
 72 |   /**
 73 |    * Generate image cache key
 74 |    */
 75 |   static generateImageKey(fileKey: string, nodeId: string, format: string): string {
 76 |     const keyString = `${fileKey}_${nodeId}_${format}`;
 77 |     return crypto.createHash("md5").update(keyString).digest("hex");
 78 |   }
 79 | 
 80 |   // ==================== Node Data Operations ====================
 81 | 
 82 |   /**
 83 |    * Get cached node data
 84 |    */
 85 |   async get<T>(
 86 |     fileKey: string,
 87 |     nodeId?: string,
 88 |     depth?: number,
 89 |     version?: string,
 90 |   ): Promise<T | null> {
 91 |     try {
 92 |       const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
 93 |       const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
 94 |       const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
 95 | 
 96 |       // Check if files exist
 97 |       if (!fs.existsSync(dataPath) || !fs.existsSync(metadataPath)) {
 98 |         this.stats.misses++;
 99 |         return null;
100 |       }
101 | 
102 |       // Read metadata and check expiration
103 |       const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
104 | 
105 |       // Check TTL expiration
106 |       if (Date.now() > metadata.expiresAt) {
107 |         this.deleteByKey(cacheKey);
108 |         this.stats.misses++;
109 |         return null;
110 |       }
111 | 
112 |       // Check version mismatch
113 |       if (version && metadata.version && metadata.version !== version) {
114 |         this.deleteByKey(cacheKey);
115 |         this.stats.misses++;
116 |         return null;
117 |       }
118 | 
119 |       // Read and return cached data
120 |       const data = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
121 |       this.stats.hits++;
122 |       return data as T;
123 |     } catch (error) {
124 |       console.warn("Failed to read disk cache:", error);
125 |       this.stats.misses++;
126 |       return null;
127 |     }
128 |   }
129 | 
130 |   /**
131 |    * Set node data cache
132 |    */
133 |   async set<T>(
134 |     data: T,
135 |     fileKey: string,
136 |     nodeId?: string,
137 |     depth?: number,
138 |     version?: string,
139 |   ): Promise<void> {
140 |     try {
141 |       const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
142 |       const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
143 |       const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
144 | 
145 |       const dataString = JSON.stringify(data, null, 2);
146 | 
147 |       // Create metadata
148 |       const metadata: CacheEntryMeta = {
149 |         key: cacheKey,
150 |         createdAt: Date.now(),
151 |         expiresAt: Date.now() + this.config.ttl,
152 |         fileKey,
153 |         nodeId,
154 |         depth,
155 |         version,
156 |         size: Buffer.byteLength(dataString, "utf-8"),
157 |       };
158 | 
159 |       // Write data and metadata
160 |       fs.writeFileSync(dataPath, dataString);
161 |       fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
162 | 
163 |       // Enforce size limit asynchronously
164 |       this.enforceSizeLimit().catch(() => {});
165 |     } catch (error) {
166 |       console.warn("Failed to write disk cache:", error);
167 |     }
168 |   }
169 | 
170 |   /**
171 |    * Check if cache entry exists
172 |    */
173 |   async has(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> {
174 |     const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
175 |     const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
176 |     const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
177 | 
178 |     if (!fs.existsSync(dataPath) || !fs.existsSync(metadataPath)) {
179 |       return false;
180 |     }
181 | 
182 |     try {
183 |       const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
184 |       return Date.now() <= metadata.expiresAt;
185 |     } catch {
186 |       return false;
187 |     }
188 |   }
189 | 
190 |   /**
191 |    * Delete cache entry by key
192 |    */
193 |   private deleteByKey(cacheKey: string): void {
194 |     try {
195 |       const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
196 |       const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
197 | 
198 |       if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath);
199 |       if (fs.existsSync(metadataPath)) fs.unlinkSync(metadataPath);
200 |     } catch {
201 |       // Ignore deletion errors
202 |     }
203 |   }
204 | 
205 |   /**
206 |    * Delete cache for a file key
207 |    */
208 |   async delete(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> {
209 |     const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
210 |     this.deleteByKey(cacheKey);
211 |     return true;
212 |   }
213 | 
214 |   /**
215 |    * Invalidate all cache entries for a file
216 |    */
217 |   async invalidateFile(fileKey: string): Promise<number> {
218 |     let invalidated = 0;
219 | 
220 |     try {
221 |       const metadataFiles = fs.readdirSync(this.metadataDir);
222 | 
223 |       for (const file of metadataFiles) {
224 |         if (!file.endsWith(".meta.json") || file.startsWith("img_")) continue;
225 | 
226 |         const metadataPath = path.join(this.metadataDir, file);
227 |         try {
228 |           const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
229 | 
230 |           if (metadata.fileKey === fileKey) {
231 |             const cacheKey = file.replace(".meta.json", "");
232 |             this.deleteByKey(cacheKey);
233 |             invalidated++;
234 |           }
235 |         } catch {
236 |           // Skip individual file errors
237 |         }
238 |       }
239 |     } catch (error) {
240 |       console.warn("Failed to invalidate file cache:", error);
241 |     }
242 | 
243 |     return invalidated;
244 |   }
245 | 
246 |   // ==================== Image Operations ====================
247 | 
248 |   /**
249 |    * Check if image is cached
250 |    */
251 |   async hasImage(fileKey: string, nodeId: string, format: string): Promise<string | null> {
252 |     try {
253 |       const cacheKey = DiskCache.generateImageKey(fileKey, nodeId, format);
254 |       const imagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`);
255 |       const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`);
256 | 
257 |       if (!fs.existsSync(imagePath) || !fs.existsSync(metadataPath)) {
258 |         return null;
259 |       }
260 | 
261 |       // Check expiration
262 |       const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
263 |       if (Date.now() > metadata.expiresAt) {
264 |         this.deleteImageByKey(cacheKey, format);
265 |         return null;
266 |       }
267 | 
268 |       return imagePath;
269 |     } catch {
270 |       return null;
271 |     }
272 |   }
273 | 
274 |   /**
275 |    * Cache image file
276 |    */
277 |   async cacheImage(
278 |     sourcePath: string,
279 |     fileKey: string,
280 |     nodeId: string,
281 |     format: string,
282 |   ): Promise<string> {
283 |     try {
284 |       const cacheKey = DiskCache.generateImageKey(fileKey, nodeId, format);
285 |       const cachedImagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`);
286 |       const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`);
287 | 
288 |       // Copy image to cache
289 |       fs.copyFileSync(sourcePath, cachedImagePath);
290 | 
291 |       // Get file size
292 |       const stats = fs.statSync(cachedImagePath);
293 | 
294 |       // Create metadata
295 |       const metadata: CacheEntryMeta = {
296 |         key: cacheKey,
297 |         createdAt: Date.now(),
298 |         expiresAt: Date.now() + this.config.ttl,
299 |         fileKey,
300 |         nodeId,
301 |         size: stats.size,
302 |       };
303 |       fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
304 | 
305 |       return cachedImagePath;
306 |     } catch (error) {
307 |       console.warn("Failed to cache image:", error);
308 |       return sourcePath;
309 |     }
310 |   }
311 | 
312 |   /**
313 |    * Copy image from cache to target path
314 |    */
315 |   async copyImageFromCache(
316 |     fileKey: string,
317 |     nodeId: string,
318 |     format: string,
319 |     targetPath: string,
320 |   ): Promise<boolean> {
321 |     const cachedPath = await this.hasImage(fileKey, nodeId, format);
322 |     if (!cachedPath) return false;
323 | 
324 |     try {
325 |       const targetDir = path.dirname(targetPath);
326 |       if (!fs.existsSync(targetDir)) {
327 |         fs.mkdirSync(targetDir, { recursive: true });
328 |       }
329 | 
330 |       fs.copyFileSync(cachedPath, targetPath);
331 |       return true;
332 |     } catch {
333 |       return false;
334 |     }
335 |   }
336 | 
337 |   /**
338 |    * Delete image cache by key
339 |    */
340 |   private deleteImageByKey(cacheKey: string, format: string): void {
341 |     try {
342 |       const imagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`);
343 |       const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`);
344 | 
345 |       if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath);
346 |       if (fs.existsSync(metadataPath)) fs.unlinkSync(metadataPath);
347 |     } catch {
348 |       // Ignore errors
349 |     }
350 |   }
351 | 
352 |   // ==================== Maintenance Operations ====================
353 | 
354 |   /**
355 |    * Clean all expired cache entries
356 |    */
357 |   async cleanExpired(): Promise<number> {
358 |     let deletedCount = 0;
359 |     const now = Date.now();
360 | 
361 |     try {
362 |       const metadataFiles = fs.readdirSync(this.metadataDir);
363 | 
364 |       for (const file of metadataFiles) {
365 |         if (!file.endsWith(".meta.json")) continue;
366 | 
367 |         const metadataPath = path.join(this.metadataDir, file);
368 |         try {
369 |           const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
370 | 
371 |           if (now > metadata.expiresAt) {
372 |             const cacheKey = file.replace(".meta.json", "");
373 | 
374 |             if (file.startsWith("img_")) {
375 |               // Image cache
376 |               const imgCacheKey = cacheKey.replace("img_", "");
377 |               ["png", "jpg", "svg", "pdf"].forEach((format) => {
378 |                 const imagePath = path.join(this.imageDir, `${imgCacheKey}.${format}`);
379 |                 if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath);
380 |               });
381 |             } else {
382 |               // Data cache
383 |               const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
384 |               if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath);
385 |             }
386 | 
387 |             fs.unlinkSync(metadataPath);
388 |             deletedCount++;
389 |           }
390 |         } catch {
391 |           // Skip individual errors
392 |         }
393 |       }
394 |     } catch (error) {
395 |       console.warn("Failed to clean expired cache:", error);
396 |     }
397 | 
398 |     return deletedCount;
399 |   }
400 | 
401 |   /**
402 |    * Enforce size limit by removing oldest entries
403 |    */
404 |   async enforceSizeLimit(): Promise<number> {
405 |     let removedCount = 0;
406 | 
407 |     try {
408 |       const stats = await this.getStats();
409 |       if (stats.totalSize <= this.config.maxSize) {
410 |         return 0;
411 |       }
412 | 
413 |       // Get all metadata entries sorted by creation time
414 |       const entries: Array<{ path: string; meta: CacheEntryMeta }> = [];
415 |       const metadataFiles = fs.readdirSync(this.metadataDir);
416 | 
417 |       for (const file of metadataFiles) {
418 |         if (!file.endsWith(".meta.json")) continue;
419 | 
420 |         const metadataPath = path.join(this.metadataDir, file);
421 |         try {
422 |           const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
423 |           entries.push({ path: metadataPath, meta: metadata });
424 |         } catch {
425 |           // Skip invalid entries
426 |         }
427 |       }
428 | 
429 |       // Sort by creation time (oldest first)
430 |       entries.sort((a, b) => a.meta.createdAt - b.meta.createdAt);
431 | 
432 |       // Remove oldest entries until under limit
433 |       let currentSize = stats.totalSize;
434 |       for (const entry of entries) {
435 |         if (currentSize <= this.config.maxSize) break;
436 | 
437 |         const cacheKey = path.basename(entry.path).replace(".meta.json", "");
438 | 
439 |         if (cacheKey.startsWith("img_")) {
440 |           const imgKey = cacheKey.replace("img_", "");
441 |           ["png", "jpg", "svg", "pdf"].forEach((format) => {
442 |             const imagePath = path.join(this.imageDir, `${imgKey}.${format}`);
443 |             if (fs.existsSync(imagePath)) {
444 |               currentSize -= fs.statSync(imagePath).size;
445 |               fs.unlinkSync(imagePath);
446 |             }
447 |           });
448 |         } else {
449 |           const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
450 |           if (fs.existsSync(dataPath)) {
451 |             currentSize -= fs.statSync(dataPath).size;
452 |             fs.unlinkSync(dataPath);
453 |           }
454 |         }
455 | 
456 |         fs.unlinkSync(entry.path);
457 |         currentSize -= entry.meta.size || 0;
458 |         removedCount++;
459 |       }
460 |     } catch (error) {
461 |       console.warn("Failed to enforce size limit:", error);
462 |     }
463 | 
464 |     return removedCount;
465 |   }
466 | 
467 |   /**
468 |    * Clear all cache
469 |    */
470 |   async clearAll(): Promise<void> {
471 |     try {
472 |       [this.dataDir, this.imageDir, this.metadataDir].forEach((dir) => {
473 |         if (fs.existsSync(dir)) {
474 |           const files = fs.readdirSync(dir);
475 |           files.forEach((file) => {
476 |             fs.unlinkSync(path.join(dir, file));
477 |           });
478 |         }
479 |       });
480 |       this.stats = { hits: 0, misses: 0 };
481 |     } catch (error) {
482 |       console.warn("Failed to clear cache:", error);
483 |     }
484 |   }
485 | 
486 |   /**
487 |    * Get cache statistics
488 |    */
489 |   async getStats(): Promise<DiskCacheStats> {
490 |     let totalSize = 0;
491 |     let nodeFileCount = 0;
492 |     let imageFileCount = 0;
493 | 
494 |     try {
495 |       if (fs.existsSync(this.dataDir)) {
496 |         const dataFiles = fs.readdirSync(this.dataDir);
497 |         nodeFileCount = dataFiles.filter((f) => f.endsWith(".json")).length;
498 |         dataFiles.forEach((file) => {
499 |           const stat = fs.statSync(path.join(this.dataDir, file));
500 |           totalSize += stat.size;
501 |         });
502 |       }
503 | 
504 |       if (fs.existsSync(this.imageDir)) {
505 |         const imageFiles = fs.readdirSync(this.imageDir);
506 |         imageFileCount = imageFiles.length;
507 |         imageFiles.forEach((file) => {
508 |           const stat = fs.statSync(path.join(this.imageDir, file));
509 |           totalSize += stat.size;
510 |         });
511 |       }
512 |     } catch {
513 |       // Ignore errors
514 |     }
515 | 
516 |     return {
517 |       hits: this.stats.hits,
518 |       misses: this.stats.misses,
519 |       totalSize,
520 |       maxSize: this.config.maxSize,
521 |       nodeFileCount,
522 |       imageFileCount,
523 |     };
524 |   }
525 | 
526 |   /**
527 |    * Get cache directory path
528 |    */
529 |   getCacheDir(): string {
530 |     return this.config.cacheDir;
531 |   }
532 | }
533 | 
```

--------------------------------------------------------------------------------
/src/algorithms/icon/detector.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Icon Detection Algorithm
  3 |  *
  4 |  * Industry-based algorithm for detecting icons and mergeable layer groups.
  5 |  *
  6 |  * Core strategies:
  7 |  * 1. Prioritize Figma exportSettings (designer-marked exports)
  8 |  * 2. Smart detection: based on size, type ratio, structure depth
  9 |  * 3. Bottom-up merging: child icon groups merge first, then parent nodes
 10 |  *
 11 |  * @module algorithms/icon/detector
 12 |  */
 13 | 
 14 | import type { IconDetectionResult, IconDetectionConfig } from "~/types/index.js";
 15 | 
 16 | // Re-export types for module consumers
 17 | export type { IconDetectionResult };
 18 | 
 19 | // Use IconDetectionConfig from types, alias as DetectionConfig for internal use
 20 | export type DetectionConfig = IconDetectionConfig;
 21 | 
 22 | // ==================== Module-Specific Types ====================
 23 | 
 24 | /**
 25 |  * Figma node structure for icon detection (minimal interface)
 26 |  */
 27 | export interface FigmaNode {
 28 |   id: string;
 29 |   name: string;
 30 |   type: string;
 31 |   children?: FigmaNode[];
 32 |   absoluteBoundingBox?: {
 33 |     x: number;
 34 |     y: number;
 35 |     width: number;
 36 |     height: number;
 37 |   };
 38 |   exportSettings?: Array<{
 39 |     format: string;
 40 |     suffix?: string;
 41 |     constraint?: {
 42 |       type: string;
 43 |       value: number;
 44 |     };
 45 |   }>;
 46 |   fills?: Array<{
 47 |     type: string;
 48 |     visible?: boolean;
 49 |     imageRef?: string;
 50 |     blendMode?: string;
 51 |   }>;
 52 |   effects?: Array<{
 53 |     type: string;
 54 |     visible?: boolean;
 55 |   }>;
 56 |   strokes?: Array<unknown>;
 57 | }
 58 | 
 59 | // ==================== Constants ====================
 60 | 
 61 | /** Default detection configuration */
 62 | export const DEFAULT_CONFIG: DetectionConfig = {
 63 |   maxIconSize: 300,
 64 |   minIconSize: 8,
 65 |   mergeableRatio: 0.6,
 66 |   maxDepth: 5,
 67 |   maxChildren: 100,
 68 |   respectExportSettingsMaxSize: 400,
 69 | };
 70 | 
 71 | /** Container node types */
 72 | const CONTAINER_TYPES = ["GROUP", "FRAME", "COMPONENT", "INSTANCE"] as const;
 73 | 
 74 | /** Mergeable graphics types (can be represented as SVG) */
 75 | const MERGEABLE_TYPES = [
 76 |   "VECTOR",
 77 |   "RECTANGLE",
 78 |   "ELLIPSE",
 79 |   "LINE",
 80 |   "POLYGON",
 81 |   "STAR",
 82 |   "BOOLEAN_OPERATION",
 83 |   "REGULAR_POLYGON",
 84 | ] as const;
 85 | 
 86 | /** Single element types that should not be auto-exported (typically backgrounds) */
 87 | const SINGLE_ELEMENT_EXCLUDE_TYPES = ["RECTANGLE"] as const;
 88 | 
 89 | /** Types that exclude a group from being merged as icon */
 90 | const EXCLUDE_TYPES = ["TEXT", "COMPONENT", "INSTANCE"] as const;
 91 | 
 92 | /** Effects that require PNG export */
 93 | const PNG_REQUIRED_EFFECTS = [
 94 |   "DROP_SHADOW",
 95 |   "INNER_SHADOW",
 96 |   "LAYER_BLUR",
 97 |   "BACKGROUND_BLUR",
 98 | ] as const;
 99 | 
100 | // ==================== Helper Functions ====================
101 | 
102 | /**
103 |  * Check if type is a container type
104 |  */
105 | function isContainerType(type: string): boolean {
106 |   return CONTAINER_TYPES.includes(type as (typeof CONTAINER_TYPES)[number]);
107 | }
108 | 
109 | /**
110 |  * Check if type is mergeable (can be part of an icon)
111 |  */
112 | function isMergeableType(type: string): boolean {
113 |   return MERGEABLE_TYPES.includes(type as (typeof MERGEABLE_TYPES)[number]);
114 | }
115 | 
116 | /**
117 |  * Check if type should be excluded from icon merging
118 |  */
119 | function isExcludeType(type: string): boolean {
120 |   return EXCLUDE_TYPES.includes(type as (typeof EXCLUDE_TYPES)[number]);
121 | }
122 | 
123 | /**
124 |  * Get node dimensions
125 |  */
126 | function getNodeSize(node: FigmaNode): { width: number; height: number } | null {
127 |   if (!node.absoluteBoundingBox) return null;
128 |   return {
129 |     width: node.absoluteBoundingBox.width,
130 |     height: node.absoluteBoundingBox.height,
131 |   };
132 | }
133 | 
134 | /**
135 |  * Check if node has image fill
136 |  */
137 | function hasImageFill(node: FigmaNode): boolean {
138 |   if (!node.fills) return false;
139 |   return node.fills.some(
140 |     (fill) => fill.type === "IMAGE" && fill.visible !== false && fill.imageRef,
141 |   );
142 | }
143 | 
144 | /**
145 |  * Check if node has complex effects (requires PNG)
146 |  */
147 | function hasComplexEffects(node: FigmaNode): boolean {
148 |   if (!node.effects) return false;
149 |   return node.effects.some(
150 |     (effect) =>
151 |       effect.visible !== false &&
152 |       PNG_REQUIRED_EFFECTS.includes(effect.type as (typeof PNG_REQUIRED_EFFECTS)[number]),
153 |   );
154 | }
155 | 
156 | // ==================== Optimized Single-Pass Stats Collection ====================
157 | 
158 | /**
159 |  * Statistics collected from a node tree in a single traversal
160 |  * This replaces multiple recursive functions with one unified pass
161 |  */
162 | interface NodeTreeStats {
163 |   /** Maximum depth of the tree */
164 |   depth: number;
165 |   /** Total number of descendants (not including root) */
166 |   totalChildren: number;
167 |   /** Whether tree contains excluded types (TEXT, COMPONENT, INSTANCE) */
168 |   hasExcludeType: boolean;
169 |   /** Whether tree contains image fills */
170 |   hasImageFill: boolean;
171 |   /** Whether tree contains complex effects requiring PNG */
172 |   hasComplexEffects: boolean;
173 |   /** Whether all leaf nodes are mergeable types */
174 |   allLeavesMergeable: boolean;
175 |   /** Ratio of mergeable types in direct children */
176 |   mergeableRatio: number;
177 | }
178 | 
179 | /**
180 |  * Collect all tree statistics in a single traversal
181 |  *
182 |  * OPTIMIZATION: This replaces 6 separate recursive functions:
183 |  * - calculateDepth()
184 |  * - countTotalChildren()
185 |  * - hasExcludeTypeInTree()
186 |  * - hasImageFillInTree()
187 |  * - hasComplexEffectsInTree()
188 |  * - areAllLeavesMergeable()
189 |  *
190 |  * Before: O(6n) - each function traverses entire tree
191 |  * After: O(n) - single traversal collects all data
192 |  *
193 |  * @param node - Node to analyze
194 |  * @returns Collected statistics
195 |  */
196 | function collectNodeStats(node: FigmaNode): NodeTreeStats {
197 |   // Base case: leaf node (no children)
198 |   if (!node.children || node.children.length === 0) {
199 |     const isMergeable = isMergeableType(node.type);
200 |     return {
201 |       depth: 0,
202 |       totalChildren: 0,
203 |       hasExcludeType: isExcludeType(node.type),
204 |       hasImageFill: hasImageFill(node),
205 |       hasComplexEffects: hasComplexEffects(node),
206 |       allLeavesMergeable: isMergeable,
207 |       mergeableRatio: isMergeable ? 1 : 0,
208 |     };
209 |   }
210 | 
211 |   // Recursive case: collect stats from all children
212 |   const childStats = node.children.map(collectNodeStats);
213 | 
214 |   // Aggregate child statistics
215 |   const maxChildDepth = Math.max(...childStats.map((s) => s.depth));
216 |   const totalDescendants = childStats.reduce((sum, s) => sum + 1 + s.totalChildren, 0);
217 |   const hasExcludeInChildren = childStats.some((s) => s.hasExcludeType);
218 |   const hasImageInChildren = childStats.some((s) => s.hasImageFill);
219 |   const hasEffectsInChildren = childStats.some((s) => s.hasComplexEffects);
220 |   const allChildrenMergeable = childStats.every((s) => s.allLeavesMergeable);
221 | 
222 |   // Calculate mergeable ratio for direct children
223 |   const mergeableCount = node.children.filter(
224 |     (child) => isMergeableType(child.type) || isContainerType(child.type),
225 |   ).length;
226 |   const mergeableRatio = mergeableCount / node.children.length;
227 | 
228 |   // Determine if all leaves are mergeable
229 |   // For containers: all children must have all leaves mergeable
230 |   // For other types: check if this type itself is mergeable
231 |   const allLeavesMergeable = isContainerType(node.type)
232 |     ? allChildrenMergeable
233 |     : isMergeableType(node.type);
234 | 
235 |   return {
236 |     depth: maxChildDepth + 1,
237 |     totalChildren: totalDescendants,
238 |     hasExcludeType: isExcludeType(node.type) || hasExcludeInChildren,
239 |     hasImageFill: hasImageFill(node) || hasImageInChildren,
240 |     hasComplexEffects: hasComplexEffects(node) || hasEffectsInChildren,
241 |     allLeavesMergeable,
242 |     mergeableRatio,
243 |   };
244 | }
245 | 
246 | // ==================== Main Detection Functions ====================
247 | 
248 | /**
249 |  * Detect if a single node should be exported as an icon
250 |  *
251 |  * OPTIMIZED: Uses single-pass collectNodeStats() instead of multiple recursive functions
252 |  *
253 |  * @param node - Figma node to analyze
254 |  * @param config - Detection configuration
255 |  * @returns Detection result with export recommendation
256 |  */
257 | export function detectIcon(
258 |   node: FigmaNode,
259 |   config: DetectionConfig = DEFAULT_CONFIG,
260 | ): IconDetectionResult {
261 |   const result: IconDetectionResult = {
262 |     nodeId: node.id,
263 |     nodeName: node.name,
264 |     shouldMerge: false,
265 |     exportFormat: "SVG",
266 |     reason: "",
267 |   };
268 | 
269 |   // Get node size once
270 |   const size = getNodeSize(node);
271 |   if (size) {
272 |     result.size = size;
273 |   }
274 | 
275 |   // 1. Check Figma exportSettings (with size restrictions)
276 |   if (node.exportSettings && node.exportSettings.length > 0) {
277 |     const isSmallEnough =
278 |       !size ||
279 |       (size.width <= config.respectExportSettingsMaxSize &&
280 |         size.height <= config.respectExportSettingsMaxSize);
281 | 
282 |     // For exportSettings, we need to check for excluded types
283 |     // Use optimized single-pass collection
284 |     const stats = collectNodeStats(node);
285 |     const containsText = stats.hasExcludeType;
286 | 
287 |     if (isSmallEnough && !containsText) {
288 |       const exportSetting = node.exportSettings[0];
289 |       result.shouldMerge = true;
290 |       result.exportFormat = exportSetting.format === "SVG" ? "SVG" : "PNG";
291 |       result.reason = `Designer marked export as ${exportSetting.format}`;
292 |       return result;
293 |     }
294 |     // Large nodes or nodes with TEXT: ignore exportSettings, continue detection
295 |   }
296 | 
297 |   // 2. Must be container type or mergeable single element
298 |   if (!isContainerType(node.type)) {
299 |     // Single mergeable type node
300 |     if (isMergeableType(node.type)) {
301 |       // Single RECTANGLE is typically a background, not exported
302 |       if (
303 |         SINGLE_ELEMENT_EXCLUDE_TYPES.includes(
304 |           node.type as (typeof SINGLE_ELEMENT_EXCLUDE_TYPES)[number],
305 |         )
306 |       ) {
307 |         result.reason = `Single ${node.type} is typically a background, not exported`;
308 |         return result;
309 |       }
310 | 
311 |       // Check size for single elements
312 |       if (size) {
313 |         if (size.width > config.maxIconSize || size.height > config.maxIconSize) {
314 |           result.reason = `Single element too large (${Math.round(size.width)}x${Math.round(size.height)} > ${config.maxIconSize})`;
315 |           return result;
316 |         }
317 |       }
318 |       result.shouldMerge = true;
319 |       result.exportFormat = hasComplexEffects(node) ? "PNG" : "SVG";
320 |       result.reason = "Single vector/shape element";
321 |       return result;
322 |     }
323 |     result.reason = "Not a container or mergeable type";
324 |     return result;
325 |   }
326 | 
327 |   // 3. Check size
328 |   if (size) {
329 |     // Too large: likely a layout container
330 |     if (size.width > config.maxIconSize || size.height > config.maxIconSize) {
331 |       result.reason = `Size too large (${size.width}x${size.height} > ${config.maxIconSize})`;
332 |       return result;
333 |     }
334 | 
335 |     // Too small
336 |     if (size.width < config.minIconSize && size.height < config.minIconSize) {
337 |       result.reason = `Size too small (${size.width}x${size.height} < ${config.minIconSize})`;
338 |       return result;
339 |     }
340 |   }
341 | 
342 |   // OPTIMIZATION: Collect all tree statistics in a single pass
343 |   // This replaces 6 separate recursive traversals with 1
344 |   const stats = collectNodeStats(node);
345 | 
346 |   // 4. Check for excluded types (TEXT, etc.)
347 |   if (stats.hasExcludeType) {
348 |     result.reason = "Contains TEXT or other exclude types";
349 |     return result;
350 |   }
351 | 
352 |   // 5. Check structure depth
353 |   if (stats.depth > config.maxDepth) {
354 |     result.reason = `Depth too deep (${stats.depth} > ${config.maxDepth})`;
355 |     return result;
356 |   }
357 | 
358 |   // 6. Check child count
359 |   result.childCount = stats.totalChildren;
360 |   if (stats.totalChildren > config.maxChildren) {
361 |     result.reason = `Too many children (${stats.totalChildren} > ${config.maxChildren})`;
362 |     return result;
363 |   }
364 | 
365 |   // 7. Check mergeable type ratio
366 |   if (stats.mergeableRatio < config.mergeableRatio) {
367 |     result.reason = `Mergeable ratio too low (${(stats.mergeableRatio * 100).toFixed(1)}% < ${config.mergeableRatio * 100}%)`;
368 |     return result;
369 |   }
370 | 
371 |   // 8. Check if all leaf nodes are mergeable
372 |   if (!stats.allLeavesMergeable) {
373 |     result.reason = "Not all leaf nodes are mergeable types";
374 |     return result;
375 |   }
376 | 
377 |   // 9. Determine export format (using stats collected in single pass)
378 |   if (stats.hasImageFill) {
379 |     result.exportFormat = "PNG";
380 |     result.reason = "Contains image fills, export as PNG";
381 |   } else if (stats.hasComplexEffects) {
382 |     result.exportFormat = "PNG";
383 |     result.reason = "Contains complex effects, export as PNG";
384 |   } else {
385 |     result.exportFormat = "SVG";
386 |     result.reason = "All vector elements, export as SVG";
387 |   }
388 | 
389 |   result.shouldMerge = true;
390 |   return result;
391 | }
392 | 
393 | /**
394 |  * Process node tree bottom-up, detecting and marking icons
395 |  *
396 |  * @param node - Root node
397 |  * @param config - Detection configuration
398 |  * @returns Processed node with _iconDetection markers
399 |  */
400 | export function processNodeTree(
401 |   node: FigmaNode,
402 |   config: DetectionConfig = DEFAULT_CONFIG,
403 | ): FigmaNode & { _iconDetection?: IconDetectionResult } {
404 |   const processedNode = { ...node } as FigmaNode & { _iconDetection?: IconDetectionResult };
405 | 
406 |   // Process children first (bottom-up)
407 |   if (node.children && node.children.length > 0) {
408 |     processedNode.children = node.children.map((child) => processNodeTree(child, config));
409 | 
410 |     // Check if all children are marked as icons (can be merged to parent)
411 |     const allChildrenAreIcons = processedNode.children.every((child) => {
412 |       const childWithDetection = child as FigmaNode & { _iconDetection?: IconDetectionResult };
413 |       return childWithDetection._iconDetection?.shouldMerge;
414 |     });
415 | 
416 |     // If all children are icons, try to merge to current node
417 |     if (allChildrenAreIcons) {
418 |       const detection = detectIcon(processedNode, config);
419 |       if (detection.shouldMerge) {
420 |         processedNode._iconDetection = detection;
421 |         // Clear child markers since they will be merged
422 |         processedNode.children.forEach((child) => {
423 |           delete (child as FigmaNode & { _iconDetection?: IconDetectionResult })._iconDetection;
424 |         });
425 |         return processedNode;
426 |       }
427 |     }
428 |   }
429 | 
430 |   // Detect current node
431 |   const detection = detectIcon(processedNode, config);
432 |   if (detection.shouldMerge) {
433 |     processedNode._iconDetection = detection;
434 |   }
435 | 
436 |   return processedNode;
437 | }
438 | 
439 | /**
440 |  * Collect all exportable icons from processed node tree
441 |  *
442 |  * @param node - Processed node with _iconDetection markers
443 |  * @returns Array of icon detection results
444 |  */
445 | export function collectExportableIcons(
446 |   node: FigmaNode & { _iconDetection?: IconDetectionResult },
447 | ): IconDetectionResult[] {
448 |   const results: IconDetectionResult[] = [];
449 | 
450 |   // If current node is an icon, add to results
451 |   if (node._iconDetection?.shouldMerge) {
452 |     results.push(node._iconDetection);
453 |     // Don't recurse into children (they will be merged)
454 |     return results;
455 |   }
456 | 
457 |   // Recurse into children
458 |   if (node.children) {
459 |     for (const child of node.children) {
460 |       results.push(
461 |         ...collectExportableIcons(child as FigmaNode & { _iconDetection?: IconDetectionResult }),
462 |       );
463 |     }
464 |   }
465 | 
466 |   return results;
467 | }
468 | 
469 | /**
470 |  * Analyze node tree and return icon detection report
471 |  *
472 |  * @param node - Root Figma node
473 |  * @param config - Detection configuration
474 |  * @returns Analysis result with processed tree, exportable icons, and summary
475 |  */
476 | export function analyzeNodeTree(
477 |   node: FigmaNode,
478 |   config: DetectionConfig = DEFAULT_CONFIG,
479 | ): {
480 |   processedTree: FigmaNode & { _iconDetection?: IconDetectionResult };
481 |   exportableIcons: IconDetectionResult[];
482 |   summary: {
483 |     totalIcons: number;
484 |     svgCount: number;
485 |     pngCount: number;
486 |   };
487 | } {
488 |   const processedTree = processNodeTree(node, config);
489 |   const exportableIcons = collectExportableIcons(processedTree);
490 | 
491 |   const summary = {
492 |     totalIcons: exportableIcons.length,
493 |     svgCount: exportableIcons.filter((i) => i.exportFormat === "SVG").length,
494 |     pngCount: exportableIcons.filter((i) => i.exportFormat === "PNG").length,
495 |   };
496 | 
497 |   return { processedTree, exportableIcons, summary };
498 | }
499 | 
```

--------------------------------------------------------------------------------
/tests/integration/layout-optimization.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Layout Optimization Integration Tests
  3 |  *
  4 |  * Tests the complete layout optimization pipeline using real Figma data.
  5 |  * Converted from scripts/test-layout-optimization.ts
  6 |  */
  7 | 
  8 | import { describe, it, expect, beforeAll } from "vitest";
  9 | import * as fs from "fs";
 10 | import * as path from "path";
 11 | import { fileURLToPath } from "url";
 12 | import { parseFigmaResponse } from "~/core/parser.js";
 13 | import { LayoutOptimizer } from "~/algorithms/layout/optimizer.js";
 14 | import type { SimplifiedNode, SimplifiedDesign } from "~/types/index.js";
 15 | 
 16 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
 17 | const fixturesDir = path.join(__dirname, "../fixtures/figma-data");
 18 | const expectedDir = path.join(__dirname, "../fixtures/expected");
 19 | 
 20 | // Test file configurations
 21 | const TEST_FILES = [
 22 |   { name: "node-402-34955", desc: "Group 1410104853 (1580x895)" },
 23 |   { name: "node-240-32163", desc: "Call Logs 有数据 (375x827)" },
 24 | ];
 25 | 
 26 | // Layout statistics interface
 27 | interface LayoutStats {
 28 |   total: number;
 29 |   flex: number;
 30 |   grid: number;
 31 |   flexRow: number;
 32 |   flexColumn: number;
 33 |   absolute: number;
 34 | }
 35 | 
 36 | // Helper: Count layouts recursively
 37 | function countLayouts(node: SimplifiedNode, stats: LayoutStats): void {
 38 |   stats.total++;
 39 | 
 40 |   const display = node.cssStyles?.display;
 41 |   if (display === "flex") {
 42 |     stats.flex++;
 43 |     if (node.cssStyles?.flexDirection === "column") {
 44 |       stats.flexColumn++;
 45 |     } else {
 46 |       stats.flexRow++;
 47 |     }
 48 |   } else if (display === "grid") {
 49 |     stats.grid++;
 50 |   } else if (node.cssStyles?.position === "absolute") {
 51 |     stats.absolute++;
 52 |   }
 53 | 
 54 |   if (node.children) {
 55 |     for (const child of node.children) {
 56 |       countLayouts(child, stats);
 57 |     }
 58 |   }
 59 | }
 60 | 
 61 | // Helper: Find nodes by layout type
 62 | function findLayoutNodes(
 63 |   node: SimplifiedNode,
 64 |   layoutType: "flex" | "grid",
 65 |   results: SimplifiedNode[] = [],
 66 | ): SimplifiedNode[] {
 67 |   if (node.cssStyles?.display === layoutType) {
 68 |     results.push(node);
 69 |   }
 70 | 
 71 |   if (node.children) {
 72 |     for (const child of node.children) {
 73 |       findLayoutNodes(child, layoutType, results);
 74 |     }
 75 |   }
 76 | 
 77 |   return results;
 78 | }
 79 | 
 80 | // Helper: Load raw fixture data
 81 | function loadFixture(name: string): unknown {
 82 |   const filePath = path.join(fixturesDir, `${name}.json`);
 83 |   return JSON.parse(fs.readFileSync(filePath, "utf-8"));
 84 | }
 85 | 
 86 | // Helper: Load expected output if exists
 87 | function loadExpectedOutput(name: string): unknown | null {
 88 |   const filePath = path.join(expectedDir, `${name}-optimized.json`);
 89 |   if (fs.existsSync(filePath)) {
 90 |     return JSON.parse(fs.readFileSync(filePath, "utf-8"));
 91 |   }
 92 |   return null;
 93 | }
 94 | 
 95 | describe("Layout Optimization Integration", () => {
 96 |   TEST_FILES.forEach(({ name, desc }) => {
 97 |     describe(`${name} (${desc})`, () => {
 98 |       let rawData: unknown;
 99 |       let result: ReturnType<typeof parseFigmaResponse>;
100 |       let originalSize: number;
101 |       let optimizedSize: number;
102 |       let stats: LayoutStats;
103 | 
104 |       beforeAll(() => {
105 |         const filePath = path.join(fixturesDir, `${name}.json`);
106 |         if (!fs.existsSync(filePath)) {
107 |           throw new Error(`Test fixture not found: ${name}.json`);
108 |         }
109 | 
110 |         rawData = loadFixture(name);
111 |         originalSize = fs.statSync(filePath).size;
112 | 
113 |         result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
114 |         optimizedSize = Buffer.byteLength(JSON.stringify(result));
115 | 
116 |         // Calculate layout stats
117 |         stats = {
118 |           total: 0,
119 |           flex: 0,
120 |           grid: 0,
121 |           flexRow: 0,
122 |           flexColumn: 0,
123 |           absolute: 0,
124 |         };
125 |         for (const node of result.nodes) {
126 |           countLayouts(node, stats);
127 |         }
128 |       });
129 | 
130 |       describe("Data Compression", () => {
131 |         it("should achieve significant data compression", () => {
132 |           const compressionRate = (1 - optimizedSize / originalSize) * 100;
133 |           // Should achieve at least 50% compression
134 |           expect(compressionRate).toBeGreaterThan(50);
135 |         });
136 | 
137 |         it("should reduce file size to under 100KB for typical nodes", () => {
138 |           // Optimized output should be reasonable size
139 |           expect(optimizedSize).toBeLessThan(100 * 1024);
140 |         });
141 |       });
142 | 
143 |       describe("Layout Detection", () => {
144 |         it("should detect layout types (not all absolute)", () => {
145 |           // Should have some flex or grid layouts
146 |           expect(stats.flex + stats.grid).toBeGreaterThan(0);
147 |         });
148 | 
149 |         it("should not have excessive absolute positioning", () => {
150 |           // Absolute positioning should not dominate in most cases
151 |           // Some fixtures may have higher absolute ratios due to their design
152 |           const absoluteRatio = stats.absolute / stats.total;
153 |           // Allow up to 90% for complex layouts, but ensure some semantic layouts exist
154 |           expect(absoluteRatio).toBeLessThan(0.9);
155 |         });
156 | 
157 |         it("should have balanced flex row/column distribution", () => {
158 |           // At least some flex should be detected
159 |           expect(stats.flex).toBeGreaterThan(0);
160 |         });
161 |       });
162 | 
163 |       describe("Flex Layout Properties", () => {
164 |         it("should have valid flex properties when flex is detected", () => {
165 |           const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
166 | 
167 |           flexNodes.forEach((node) => {
168 |             expect(node.cssStyles?.display).toBe("flex");
169 |             // Direction should be defined
170 |             expect(["row", "column", undefined]).toContain(node.cssStyles?.flexDirection);
171 |           });
172 |         });
173 | 
174 |         it("should have gap property for flex containers with spacing", () => {
175 |           const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
176 | 
177 |           // At least some flex containers should have gap
178 |           const flexWithGap = flexNodes.filter((n) => n.cssStyles?.gap);
179 |           if (flexNodes.length > 2) {
180 |             expect(flexWithGap.length).toBeGreaterThan(0);
181 |           }
182 |         });
183 |       });
184 | 
185 |       describe("Grid Layout Properties", () => {
186 |         it("should have valid grid properties when grid is detected", () => {
187 |           const gridNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "grid"));
188 | 
189 |           gridNodes.forEach((node) => {
190 |             expect(node.cssStyles?.display).toBe("grid");
191 |             // Grid must have gridTemplateColumns
192 |             expect(node.cssStyles?.gridTemplateColumns).toBeDefined();
193 |           });
194 |         });
195 | 
196 |         it("should have at least 4 children for grid containers", () => {
197 |           const gridNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "grid"));
198 | 
199 |           gridNodes.forEach((node) => {
200 |             expect(node.children?.length).toBeGreaterThanOrEqual(4);
201 |           });
202 |         });
203 | 
204 |         it("should have valid gridTemplateColumns format", () => {
205 |           const gridNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "grid"));
206 | 
207 |           gridNodes.forEach((node) => {
208 |             const columns = node.cssStyles?.gridTemplateColumns;
209 |             if (columns) {
210 |               // Should be space-separated pixel values or repeat()
211 |               expect(columns).toMatch(/^(\d+px\s*)+$|^repeat\(/);
212 |             }
213 |           });
214 |         });
215 |       });
216 | 
217 |       describe("Child Style Cleanup", () => {
218 |         it("should clean position:absolute from flex children", () => {
219 |           const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
220 | 
221 |           flexNodes.forEach((parent) => {
222 |             parent.children?.forEach((child) => {
223 |               // Non-overlapping children should not have position:absolute
224 |               if (!hasOverlapWithSiblings(child, parent.children || [])) {
225 |                 expect(child.cssStyles?.position).not.toBe("absolute");
226 |               }
227 |             });
228 |           });
229 |         });
230 | 
231 |         it("should clean left/top from flex children", () => {
232 |           const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
233 | 
234 |           flexNodes.forEach((parent) => {
235 |             parent.children?.forEach((child) => {
236 |               if (!hasOverlapWithSiblings(child, parent.children || [])) {
237 |                 expect(child.cssStyles?.left).toBeUndefined();
238 |                 expect(child.cssStyles?.top).toBeUndefined();
239 |               }
240 |             });
241 |           });
242 |         });
243 |       });
244 | 
245 |       describe("Output Structure", () => {
246 |         it("should have correct response structure", () => {
247 |           expect(result).toHaveProperty("name");
248 |           expect(result).toHaveProperty("nodes");
249 |           expect(Array.isArray(result.nodes)).toBe(true);
250 |         });
251 | 
252 |         it("should preserve node hierarchy", () => {
253 |           expect(result.nodes.length).toBeGreaterThan(0);
254 |           const rootNode = result.nodes[0];
255 |           expect(rootNode).toHaveProperty("id");
256 |           expect(rootNode).toHaveProperty("name");
257 |           expect(rootNode).toHaveProperty("type");
258 |         });
259 |       });
260 | 
261 |       describe("Snapshot Comparison", () => {
262 |         it("should match expected output structure", () => {
263 |           const expected = loadExpectedOutput(name);
264 |           if (expected) {
265 |             // Compare node count
266 |             expect(result.nodes.length).toBe((expected as { nodes: unknown[] }).nodes.length);
267 |           }
268 |         });
269 | 
270 |         it("should produce consistent layout stats", () => {
271 |           // Use inline snapshot for layout stats
272 |           expect({
273 |             total: stats.total,
274 |             flexRatio: Math.round((stats.flex / stats.total) * 100),
275 |             gridRatio: Math.round((stats.grid / stats.total) * 100),
276 |             absoluteRatio: Math.round((stats.absolute / stats.total) * 100),
277 |           }).toMatchSnapshot();
278 |         });
279 |       });
280 |     });
281 |   });
282 | });
283 | 
284 | // Helper: Check if a node overlaps with its siblings
285 | function hasOverlapWithSiblings(node: SimplifiedNode, siblings: SimplifiedNode[]): boolean {
286 |   const nodeRect = extractRect(node);
287 |   if (!nodeRect) return false;
288 | 
289 |   return siblings.some((sibling) => {
290 |     if (sibling.id === node.id) return false;
291 |     const siblingRect = extractRect(sibling);
292 |     if (!siblingRect) return false;
293 | 
294 |     return calculateIoU(nodeRect, siblingRect) > 0.1;
295 |   });
296 | }
297 | 
298 | interface Rect {
299 |   x: number;
300 |   y: number;
301 |   width: number;
302 |   height: number;
303 | }
304 | 
305 | function extractRect(node: SimplifiedNode): Rect | null {
306 |   const styles = node.cssStyles;
307 |   if (!styles?.width || !styles?.height) return null;
308 | 
309 |   return {
310 |     x: parseFloat(styles.left || "0"),
311 |     y: parseFloat(styles.top || "0"),
312 |     width: parseFloat(styles.width),
313 |     height: parseFloat(styles.height),
314 |   };
315 | }
316 | 
317 | function calculateIoU(a: Rect, b: Rect): number {
318 |   const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
319 |   const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
320 |   const intersection = xOverlap * yOverlap;
321 | 
322 |   const areaA = a.width * a.height;
323 |   const areaB = b.width * b.height;
324 |   const union = areaA + areaB - intersection;
325 | 
326 |   return union > 0 ? intersection / union : 0;
327 | }
328 | 
329 | // ==================== Optimization Idempotency Tests ====================
330 | 
331 | describe("Layout Optimization Idempotency", () => {
332 |   // Helper: Count occurrences of a key-value pair in object tree
333 |   function countOccurrences(obj: unknown, key: string, value: string): number {
334 |     let count = 0;
335 |     function traverse(o: unknown): void {
336 |       if (o && typeof o === "object") {
337 |         if (Array.isArray(o)) {
338 |           o.forEach(traverse);
339 |         } else {
340 |           const record = o as Record<string, unknown>;
341 |           if (record[key] === value) count++;
342 |           Object.values(record).forEach(traverse);
343 |         }
344 |       }
345 |     }
346 |     traverse(obj);
347 |     return count;
348 |   }
349 | 
350 |   // Helper: Count nodes with a specific property
351 |   function countProperty(obj: unknown, prop: string): number {
352 |     let count = 0;
353 |     function traverse(o: unknown): void {
354 |       if (o && typeof o === "object") {
355 |         if (Array.isArray(o)) {
356 |           o.forEach(traverse);
357 |         } else {
358 |           const record = o as Record<string, unknown>;
359 |           if (prop in record) count++;
360 |           Object.values(record).forEach(traverse);
361 |         }
362 |       }
363 |     }
364 |     traverse(obj);
365 |     return count;
366 |   }
367 | 
368 |   TEST_FILES.forEach(({ name, desc }) => {
369 |     describe(`${name} (${desc})`, () => {
370 |       let optimizedData: SimplifiedDesign;
371 | 
372 |       beforeAll(() => {
373 |         const filePath = path.join(expectedDir, `${name}-optimized.json`);
374 |         optimizedData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
375 |       });
376 | 
377 |       it("should be idempotent (re-optimizing produces same result)", () => {
378 |         // Optimize again
379 |         const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
380 | 
381 |         // Compare key metrics
382 |         const originalAbsolute = countOccurrences(optimizedData, "position", "absolute");
383 |         const reOptimizedAbsolute = countOccurrences(reOptimized, "position", "absolute");
384 | 
385 |         const originalFlex = countOccurrences(optimizedData, "display", "flex");
386 |         const reOptimizedFlex = countOccurrences(reOptimized, "display", "flex");
387 | 
388 |         const originalGrid = countOccurrences(optimizedData, "display", "grid");
389 |         const reOptimizedGrid = countOccurrences(reOptimized, "display", "grid");
390 | 
391 |         // Re-optimization should not change layout counts significantly
392 |         // (small differences possible due to background merging on first pass)
393 |         expect(reOptimizedAbsolute).toBeLessThanOrEqual(originalAbsolute);
394 |         expect(reOptimizedFlex).toBeGreaterThanOrEqual(originalFlex);
395 |         expect(reOptimizedGrid).toBeGreaterThanOrEqual(originalGrid);
396 |       });
397 | 
398 |       it("should maintain or reduce absolute positioning count", () => {
399 |         const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
400 | 
401 |         const originalAbsolute = countOccurrences(optimizedData, "position", "absolute");
402 |         const reOptimizedAbsolute = countOccurrences(reOptimized, "position", "absolute");
403 | 
404 |         // Absolute count should not increase
405 |         expect(reOptimizedAbsolute).toBeLessThanOrEqual(originalAbsolute);
406 |       });
407 | 
408 |       it("should maintain or reduce left/top property count", () => {
409 |         const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
410 | 
411 |         const originalLeft = countProperty(optimizedData, "left");
412 |         const reOptimizedLeft = countProperty(reOptimized, "left");
413 | 
414 |         const originalTop = countProperty(optimizedData, "top");
415 |         const reOptimizedTop = countProperty(reOptimized, "top");
416 | 
417 |         expect(reOptimizedLeft).toBeLessThanOrEqual(originalLeft);
418 |         expect(reOptimizedTop).toBeLessThanOrEqual(originalTop);
419 |       });
420 | 
421 |       it("should produce stable output on second re-optimization", () => {
422 |         // First re-optimization
423 |         const firstPass = LayoutOptimizer.optimizeDesign(optimizedData);
424 |         // Second re-optimization
425 |         const secondPass = LayoutOptimizer.optimizeDesign(firstPass);
426 | 
427 |         // After two passes, results should be identical (stable)
428 |         const firstPassJson = JSON.stringify(firstPass);
429 |         const secondPassJson = JSON.stringify(secondPass);
430 | 
431 |         expect(secondPassJson).toBe(firstPassJson);
432 |       });
433 | 
434 |       it("should have expected optimization metrics", () => {
435 |         const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
436 | 
437 |         expect({
438 |           absoluteCount: countOccurrences(reOptimized, "position", "absolute"),
439 |           flexCount: countOccurrences(reOptimized, "display", "flex"),
440 |           gridCount: countOccurrences(reOptimized, "display", "grid"),
441 |           paddingCount: countProperty(reOptimized, "padding"),
442 |         }).toMatchSnapshot();
443 |       });
444 |     });
445 |   });
446 | });
447 | 
```

--------------------------------------------------------------------------------
/tests/unit/resources/figma-resources.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Figma Resources Unit Tests
  3 |  *
  4 |  * Tests the resource handlers that extract lightweight data from Figma files.
  5 |  */
  6 | 
  7 | import { describe, it, expect, vi } from "vitest";
  8 | import {
  9 |   getFileMetadata,
 10 |   getStyleTokens,
 11 |   getComponentList,
 12 |   getAssetList,
 13 |   createFileMetadataTemplate,
 14 |   createStylesTemplate,
 15 |   createComponentsTemplate,
 16 |   createAssetsTemplate,
 17 |   FIGMA_MCP_HELP,
 18 | } from "~/resources/figma-resources.js";
 19 | import type { SimplifiedDesign, SimplifiedNode } from "~/types/index.js";
 20 | import type { FigmaService } from "~/services/figma.js";
 21 | 
 22 | // ==================== Mock Types ====================
 23 | 
 24 | type MockFigmaService = Pick<
 25 |   FigmaService,
 26 |   "getFile" | "getNode" | "getImages" | "getImageFills" | "getRateLimitInfo"
 27 | >;
 28 | 
 29 | // ==================== Mock Data ====================
 30 | 
 31 | const createMockNode = (overrides: Partial<SimplifiedNode> = {}): SimplifiedNode => ({
 32 |   id: "node-1",
 33 |   name: "Test Node",
 34 |   type: "FRAME",
 35 |   ...overrides,
 36 | });
 37 | 
 38 | const createMockDesign = (overrides: Partial<SimplifiedDesign> = {}): SimplifiedDesign => ({
 39 |   name: "Test Design",
 40 |   lastModified: "2024-01-15T10:30:00Z",
 41 |   thumbnailUrl: "https://figma.com/thumbnail.png",
 42 |   nodes: [],
 43 |   ...overrides,
 44 | });
 45 | 
 46 | // Mock FigmaService
 47 | const createMockFigmaService = (design: SimplifiedDesign): MockFigmaService => ({
 48 |   getFile: vi.fn().mockResolvedValue(design),
 49 |   getNode: vi.fn().mockResolvedValue(design),
 50 |   getImages: vi.fn().mockResolvedValue([]),
 51 |   getImageFills: vi.fn().mockResolvedValue([]),
 52 |   getRateLimitInfo: vi.fn().mockReturnValue(null),
 53 | });
 54 | 
 55 | // ==================== Tests ====================
 56 | 
 57 | describe("Figma Resources", () => {
 58 |   describe("getFileMetadata", () => {
 59 |     it("should extract basic file metadata", async () => {
 60 |       const mockDesign = createMockDesign({
 61 |         name: "My Design File",
 62 |         lastModified: "2024-03-20T15:00:00Z",
 63 |         nodes: [
 64 |           createMockNode({
 65 |             id: "page-1",
 66 |             name: "Page 1",
 67 |             type: "CANVAS",
 68 |             children: [createMockNode(), createMockNode()],
 69 |           }),
 70 |           createMockNode({
 71 |             id: "page-2",
 72 |             name: "Page 2",
 73 |             type: "CANVAS",
 74 |             children: [createMockNode()],
 75 |           }),
 76 |         ],
 77 |       });
 78 | 
 79 |       const mockService = createMockFigmaService(mockDesign);
 80 |       const metadata = await getFileMetadata(mockService as FigmaService, "test-file-key");
 81 | 
 82 |       expect(metadata.name).toBe("My Design File");
 83 |       expect(metadata.lastModified).toBe("2024-03-20T15:00:00Z");
 84 |       expect(metadata.pages).toHaveLength(2);
 85 |       expect(metadata.pages[0]).toEqual({
 86 |         id: "page-1",
 87 |         name: "Page 1",
 88 |         childCount: 2,
 89 |       });
 90 |       expect(metadata.pages[1]).toEqual({
 91 |         id: "page-2",
 92 |         name: "Page 2",
 93 |         childCount: 1,
 94 |       });
 95 |     });
 96 | 
 97 |     it("should call getFile with depth 1", async () => {
 98 |       const mockDesign = createMockDesign();
 99 |       const mockService = createMockFigmaService(mockDesign);
100 | 
101 |       await getFileMetadata(mockService as FigmaService, "test-key");
102 | 
103 |       expect(mockService.getFile).toHaveBeenCalledWith("test-key", 1);
104 |     });
105 | 
106 |     it("should handle files with no pages", async () => {
107 |       const mockDesign = createMockDesign({ nodes: [] });
108 |       const mockService = createMockFigmaService(mockDesign);
109 | 
110 |       const metadata = await getFileMetadata(mockService as FigmaService, "test-key");
111 | 
112 |       expect(metadata.pages).toHaveLength(0);
113 |     });
114 | 
115 |     it("should filter out non-CANVAS nodes from pages", async () => {
116 |       const mockDesign = createMockDesign({
117 |         nodes: [
118 |           createMockNode({ id: "page-1", name: "Page", type: "CANVAS" }),
119 |           createMockNode({ id: "frame-1", name: "Frame", type: "FRAME" }),
120 |           createMockNode({ id: "page-2", name: "Page 2", type: "CANVAS" }),
121 |         ],
122 |       });
123 |       const mockService = createMockFigmaService(mockDesign);
124 | 
125 |       const metadata = await getFileMetadata(mockService as FigmaService, "test-key");
126 | 
127 |       expect(metadata.pages).toHaveLength(2);
128 |       expect(metadata.pages.every((p) => p.name.startsWith("Page"))).toBe(true);
129 |     });
130 |   });
131 | 
132 |   describe("getStyleTokens", () => {
133 |     it("should extract colors from node CSS", async () => {
134 |       const mockDesign = createMockDesign({
135 |         nodes: [
136 |           createMockNode({
137 |             name: "Primary Button",
138 |             cssStyles: {
139 |               backgroundColor: "#24C790",
140 |               color: "#FFFFFF",
141 |             },
142 |           }),
143 |         ],
144 |       });
145 |       const mockService = createMockFigmaService(mockDesign);
146 | 
147 |       const styles = await getStyleTokens(mockService as FigmaService, "test-key");
148 | 
149 |       expect(styles.colors.length).toBeGreaterThan(0);
150 |       expect(styles.colors.some((c) => c.hex === "#24C790")).toBe(true);
151 |     });
152 | 
153 |     it("should extract typography from node CSS", async () => {
154 |       const mockDesign = createMockDesign({
155 |         nodes: [
156 |           createMockNode({
157 |             name: "Heading",
158 |             cssStyles: {
159 |               fontFamily: "Inter",
160 |               fontSize: "24px",
161 |               fontWeight: "700",
162 |               lineHeight: "32px",
163 |             },
164 |           }),
165 |         ],
166 |       });
167 |       const mockService = createMockFigmaService(mockDesign);
168 | 
169 |       const styles = await getStyleTokens(mockService as FigmaService, "test-key");
170 | 
171 |       expect(styles.typography.length).toBeGreaterThan(0);
172 |       expect(styles.typography[0]).toMatchObject({
173 |         fontFamily: "Inter",
174 |         fontSize: 24,
175 |         fontWeight: 700,
176 |       });
177 |     });
178 | 
179 |     it("should extract shadow effects", async () => {
180 |       const mockDesign = createMockDesign({
181 |         nodes: [
182 |           createMockNode({
183 |             name: "Card",
184 |             cssStyles: {
185 |               boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.1)",
186 |             },
187 |           }),
188 |         ],
189 |       });
190 |       const mockService = createMockFigmaService(mockDesign);
191 | 
192 |       const styles = await getStyleTokens(mockService as FigmaService, "test-key");
193 | 
194 |       expect(styles.effects.length).toBeGreaterThan(0);
195 |       expect(styles.effects[0].type).toBe("shadow");
196 |     });
197 | 
198 |     it("should deduplicate colors", async () => {
199 |       const mockDesign = createMockDesign({
200 |         nodes: [
201 |           createMockNode({ cssStyles: { backgroundColor: "#FF0000" } }),
202 |           createMockNode({ cssStyles: { backgroundColor: "#FF0000" } }),
203 |           createMockNode({ cssStyles: { backgroundColor: "#00FF00" } }),
204 |         ],
205 |       });
206 |       const mockService = createMockFigmaService(mockDesign);
207 | 
208 |       const styles = await getStyleTokens(mockService as FigmaService, "test-key");
209 | 
210 |       const redColors = styles.colors.filter((c) => c.hex === "#FF0000");
211 |       expect(redColors.length).toBe(1);
212 |     });
213 | 
214 |     it("should limit results to avoid token bloat", async () => {
215 |       // Create many nodes with unique colors
216 |       const nodes = Array.from({ length: 50 }, (_, i) =>
217 |         createMockNode({
218 |           name: `Node ${i}`,
219 |           cssStyles: { backgroundColor: `#${i.toString(16).padStart(6, "0")}` },
220 |         }),
221 |       );
222 |       const mockDesign = createMockDesign({ nodes });
223 |       const mockService = createMockFigmaService(mockDesign);
224 | 
225 |       const styles = await getStyleTokens(mockService as FigmaService, "test-key");
226 | 
227 |       expect(styles.colors.length).toBeLessThanOrEqual(20);
228 |     });
229 | 
230 |     it("should recursively extract from children", async () => {
231 |       const mockDesign = createMockDesign({
232 |         nodes: [
233 |           createMockNode({
234 |             name: "Parent",
235 |             children: [
236 |               createMockNode({
237 |                 name: "Child",
238 |                 cssStyles: { backgroundColor: "#AABBCC" },
239 |               }),
240 |             ],
241 |           }),
242 |         ],
243 |       });
244 |       const mockService = createMockFigmaService(mockDesign);
245 | 
246 |       const styles = await getStyleTokens(mockService as FigmaService, "test-key");
247 | 
248 |       expect(styles.colors.some((c) => c.hex === "#AABBCC")).toBe(true);
249 |     });
250 |   });
251 | 
252 |   describe("getComponentList", () => {
253 |     it("should find COMPONENT nodes", async () => {
254 |       const mockDesign = createMockDesign({
255 |         nodes: [
256 |           createMockNode({
257 |             id: "comp-1",
258 |             name: "Button",
259 |             type: "COMPONENT",
260 |           }),
261 |         ],
262 |       });
263 |       const mockService = createMockFigmaService(mockDesign);
264 | 
265 |       const components = await getComponentList(mockService as FigmaService, "test-key");
266 | 
267 |       expect(components).toHaveLength(1);
268 |       expect(components[0]).toMatchObject({
269 |         id: "comp-1",
270 |         name: "Button",
271 |         type: "COMPONENT",
272 |       });
273 |     });
274 | 
275 |     it("should find COMPONENT_SET nodes with variants", async () => {
276 |       const mockDesign = createMockDesign({
277 |         nodes: [
278 |           createMockNode({
279 |             id: "set-1",
280 |             name: "Button",
281 |             type: "COMPONENT_SET",
282 |             children: [
283 |               createMockNode({ name: "Primary" }),
284 |               createMockNode({ name: "Secondary" }),
285 |               createMockNode({ name: "Outline" }),
286 |             ],
287 |           }),
288 |         ],
289 |       });
290 |       const mockService = createMockFigmaService(mockDesign);
291 | 
292 |       const components = await getComponentList(mockService as FigmaService, "test-key");
293 | 
294 |       expect(components).toHaveLength(1);
295 |       expect(components[0].type).toBe("COMPONENT_SET");
296 |       expect(components[0].variants).toEqual(["Primary", "Secondary", "Outline"]);
297 |     });
298 | 
299 |     it("should limit variants to 5", async () => {
300 |       const variants = Array.from({ length: 10 }, (_, i) =>
301 |         createMockNode({ name: `Variant ${i}` }),
302 |       );
303 |       const mockDesign = createMockDesign({
304 |         nodes: [
305 |           createMockNode({
306 |             id: "set-1",
307 |             name: "Button",
308 |             type: "COMPONENT_SET",
309 |             children: variants,
310 |           }),
311 |         ],
312 |       });
313 |       const mockService = createMockFigmaService(mockDesign);
314 | 
315 |       const components = await getComponentList(mockService as FigmaService, "test-key");
316 | 
317 |       expect(components[0].variants).toHaveLength(5);
318 |     });
319 | 
320 |     it("should find nested components", async () => {
321 |       const mockDesign = createMockDesign({
322 |         nodes: [
323 |           createMockNode({
324 |             name: "Page",
325 |             type: "CANVAS",
326 |             children: [
327 |               createMockNode({
328 |                 name: "Components",
329 |                 type: "FRAME",
330 |                 children: [
331 |                   createMockNode({ id: "c1", name: "Button", type: "COMPONENT" }),
332 |                   createMockNode({ id: "c2", name: "Input", type: "COMPONENT" }),
333 |                 ],
334 |               }),
335 |             ],
336 |           }),
337 |         ],
338 |       });
339 |       const mockService = createMockFigmaService(mockDesign);
340 | 
341 |       const components = await getComponentList(mockService as FigmaService, "test-key");
342 | 
343 |       expect(components).toHaveLength(2);
344 |     });
345 | 
346 |     it("should limit to 50 components", async () => {
347 |       const nodes = Array.from({ length: 60 }, (_, i) =>
348 |         createMockNode({
349 |           id: `comp-${i}`,
350 |           name: `Component ${i}`,
351 |           type: "COMPONENT",
352 |         }),
353 |       );
354 |       const mockDesign = createMockDesign({ nodes });
355 |       const mockService = createMockFigmaService(mockDesign);
356 | 
357 |       const components = await getComponentList(mockService as FigmaService, "test-key");
358 | 
359 |       expect(components.length).toBeLessThanOrEqual(50);
360 |     });
361 |   });
362 | 
363 |   describe("getAssetList", () => {
364 |     it("should find nodes with exportInfo", async () => {
365 |       const mockDesign = createMockDesign({
366 |         nodes: [
367 |           createMockNode({
368 |             id: "icon-1",
369 |             name: "arrow-right",
370 |             type: "VECTOR",
371 |             exportInfo: { type: "IMAGE", format: "SVG" },
372 |           }),
373 |         ],
374 |       });
375 |       const mockService = createMockFigmaService(mockDesign);
376 | 
377 |       const assets = await getAssetList(mockService as FigmaService, "test-key");
378 | 
379 |       expect(assets).toHaveLength(1);
380 |       expect(assets[0]).toMatchObject({
381 |         nodeId: "icon-1",
382 |         name: "arrow-right",
383 |         type: "icon",
384 |         exportFormats: ["SVG"],
385 |       });
386 |     });
387 | 
388 |     it("should identify icons by type VECTOR", async () => {
389 |       const mockDesign = createMockDesign({
390 |         nodes: [
391 |           createMockNode({
392 |             id: "icon-1",
393 |             name: "small-icon",
394 |             type: "VECTOR",
395 |             exportInfo: { type: "IMAGE", format: "SVG" },
396 |           }),
397 |         ],
398 |       });
399 |       const mockService = createMockFigmaService(mockDesign);
400 | 
401 |       const assets = await getAssetList(mockService as FigmaService, "test-key");
402 | 
403 |       expect(assets[0].type).toBe("icon");
404 |     });
405 | 
406 |     it("should identify large exports as vector type", async () => {
407 |       const mockDesign = createMockDesign({
408 |         nodes: [
409 |           createMockNode({
410 |             id: "illustration-1",
411 |             name: "hero-image",
412 |             type: "FRAME",
413 |             exportInfo: { type: "IMAGE_GROUP", format: "SVG" },
414 |           }),
415 |         ],
416 |       });
417 |       const mockService = createMockFigmaService(mockDesign);
418 | 
419 |       const assets = await getAssetList(mockService as FigmaService, "test-key");
420 | 
421 |       expect(assets[0].type).toBe("vector");
422 |     });
423 | 
424 |     it("should find nodes with image fills", async () => {
425 |       const mockDesign = createMockDesign({
426 |         nodes: [
427 |           createMockNode({
428 |             id: "img-1",
429 |             name: "photo",
430 |             type: "RECTANGLE",
431 |             fills: [{ type: "IMAGE", imageRef: "img:abc123" }],
432 |           }),
433 |         ],
434 |       });
435 |       const mockService = createMockFigmaService(mockDesign);
436 | 
437 |       const assets = await getAssetList(mockService as FigmaService, "test-key");
438 | 
439 |       expect(assets).toHaveLength(1);
440 |       expect(assets[0]).toMatchObject({
441 |         nodeId: "img-1",
442 |         name: "photo",
443 |         type: "image",
444 |         imageRef: "img:abc123",
445 |       });
446 |     });
447 | 
448 |     it("should find nested assets", async () => {
449 |       const mockDesign = createMockDesign({
450 |         nodes: [
451 |           createMockNode({
452 |             name: "Card",
453 |             children: [
454 |               createMockNode({
455 |                 id: "icon",
456 |                 name: "icon",
457 |                 type: "VECTOR",
458 |                 exportInfo: { type: "IMAGE", format: "SVG" },
459 |               }),
460 |               createMockNode({
461 |                 id: "image",
462 |                 name: "thumbnail",
463 |                 fills: [{ type: "IMAGE", imageRef: "img:xyz" }],
464 |               }),
465 |             ],
466 |           }),
467 |         ],
468 |       });
469 |       const mockService = createMockFigmaService(mockDesign);
470 | 
471 |       const assets = await getAssetList(mockService as FigmaService, "test-key");
472 | 
473 |       expect(assets).toHaveLength(2);
474 |     });
475 | 
476 |     it("should limit to 100 assets", async () => {
477 |       const nodes = Array.from({ length: 150 }, (_, i) =>
478 |         createMockNode({
479 |           id: `asset-${i}`,
480 |           name: `Asset ${i}`,
481 |           type: "VECTOR",
482 |           exportInfo: { type: "IMAGE", format: "SVG" },
483 |         }),
484 |       );
485 |       const mockDesign = createMockDesign({ nodes });
486 |       const mockService = createMockFigmaService(mockDesign);
487 | 
488 |       const assets = await getAssetList(mockService as FigmaService, "test-key");
489 | 
490 |       expect(assets.length).toBeLessThanOrEqual(100);
491 |     });
492 |   });
493 | 
494 |   describe("Resource Templates", () => {
495 |     it("should create file metadata template with correct URI pattern", () => {
496 |       const template = createFileMetadataTemplate();
497 |       expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}");
498 |     });
499 | 
500 |     it("should create styles template with correct URI pattern", () => {
501 |       const template = createStylesTemplate();
502 |       expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}/styles");
503 |     });
504 | 
505 |     it("should create components template with correct URI pattern", () => {
506 |       const template = createComponentsTemplate();
507 |       expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}/components");
508 |     });
509 | 
510 |     it("should create assets template with correct URI pattern", () => {
511 |       const template = createAssetsTemplate();
512 |       expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}/assets");
513 |     });
514 |   });
515 | 
516 |   describe("Help Content", () => {
517 |     it("should contain resource documentation", () => {
518 |       expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}");
519 |       expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}/styles");
520 |       expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}/components");
521 |       expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}/assets");
522 |     });
523 | 
524 |     it("should explain token costs", () => {
525 |       expect(FIGMA_MCP_HELP).toContain("Token cost");
526 |     });
527 | 
528 |     it("should explain how to get fileKey", () => {
529 |       expect(FIGMA_MCP_HELP).toContain("fileKey");
530 |       expect(FIGMA_MCP_HELP).toContain("figma.com");
531 |     });
532 |   });
533 | });
534 | 
```
Page 2/8FirstPrevNextLast