# coding=utf-8
# ======================================
# File:     test_hp_visual_spec.py
# Author:   Jackie PENG
# Contact:  jackie.pengzhao@gmail.com
# Created:  2025-03-13
# Desc:
#   Unittest for HistoryPanel 可视化规格：图表类型注册表与规格生成器（Phase 1）.
# ======================================

import unittest
import numpy as np
import pandas as pd
from typing import Any

from qteasy.history import HistoryPanel
from qteasy.hp_visual_spec import (
    ChartTypeInfo,
    ChartTypeRegistry,
    get_chart_type_registry,
    build_kline_spec,
    build_volume_spec,
    build_macd_spec,
    build_line_spec,
)
from qteasy.hp_visual_render import (
    render_spec,
    render_kline_spec,
    render_volume_spec,
    render_macd_spec,
    render_line_spec,
)


def _make_hp(htypes: list, n_shares: int = 1, n_dates: int = 20) -> HistoryPanel:
    """构造小型 HistoryPanel，形状 (n_shares, n_dates, len(htypes))，数值为简单序列。"""
    n = len(htypes)
    values = np.tile(np.arange(n_dates, dtype=float).reshape(1, -1), (n_shares, 1))
    values = np.stack([values + i * 0.1 for i in range(n)], axis=2)
    shares = [f'S{i:03d}' for i in range(n_shares)]
    rows = pd.date_range('2020-01-01', periods=n_dates, freq='B')
    return HistoryPanel(values=values, levels=shares, rows=rows, columns=htypes)


def _specs_per_group_stack_two_shares(hp: HistoryPanel):
    """
    模拟 ``HistoryPanel.plot(..., layout='stack')`` 且两只 share 时的
    ``specs_per_group`` / ``types_info`` / ``group_titles``，供交互图单测。
    """
    registry = get_chart_type_registry()
    types_info = registry.get_applicable_types(hp.htypes)
    share_list = list(hp.shares)
    if len(share_list) < 2:
        raise ValueError('need at least 2 shares')
    groups = [[share_list[0]], [share_list[1]]]
    specs_per_group = []
    group_titles = []
    for grp in groups:
        row = []
        for info in types_info:
            tid = info.id
            if tid == 'kline':
                spec = build_kline_spec(hp, shares=grp)
            elif tid == 'volume':
                spec = build_volume_spec(hp, shares=grp)
            elif tid == 'macd':
                spec = build_macd_spec(hp, shares=grp)
            else:
                spec = build_line_spec(hp, shares=grp)
            row.append(spec)
        kline_idx = next((i for i, t in enumerate(types_info) if t.id == 'kline'), None)
        vol_idx = next((i for i, t in enumerate(types_info) if t.id == 'volume'), None)
        if (
            kline_idx is not None and vol_idx is not None
            and row[kline_idx] is not None and row[vol_idx] is not None
            and 'open' in row[kline_idx].get('data', {}) and 'close' in row[kline_idx].get('data', {})
        ):
            vol_spec = dict(row[vol_idx])
            vol_spec['data'] = dict(vol_spec.get('data', {}))
            vol_spec['data']['open'] = row[kline_idx]['data']['open']
            vol_spec['data']['close'] = row[kline_idx]['data']['close']
            row[vol_idx] = vol_spec
        specs_per_group.append(row)
        group_titles.append(grp[0] if len(grp) == 1 else ','.join(grp[:3]))
    return specs_per_group, types_info, group_titles


def _specs_per_group_overlay_two_shares(hp: HistoryPanel):
    """
    模拟 ``HistoryPanel.plot(..., layout='overlay')`` 且两只 share 时的
    ``specs_per_group`` / ``types_info`` / ``group_titles``，供 Q09 测试。
    """
    registry = get_chart_type_registry()
    types_info = registry.get_applicable_types(hp.htypes)
    share_list = list(hp.shares)
    if len(share_list) < 2:
        raise ValueError('need at least 2 shares')
    grp = [share_list[0], share_list[1]]
    row = []
    for info in types_info:
        tid = info.id
        if tid == 'kline':
            spec = build_kline_spec(hp, shares=grp)
        elif tid == 'volume':
            spec = build_volume_spec(hp, shares=grp)
        elif tid == 'macd':
            spec = build_macd_spec(hp, shares=grp)
        else:
            spec = build_line_spec(hp, shares=grp)
        row.append(spec)
    kline_idx = next((i for i, t in enumerate(types_info) if t.id == 'kline'), None)
    vol_idx = next((i for i, t in enumerate(types_info) if t.id == 'volume'), None)
    if (
        kline_idx is not None and vol_idx is not None
        and row[kline_idx] is not None and row[vol_idx] is not None
        and 'open' in row[kline_idx].get('data', {}) and 'close' in row[kline_idx].get('data', {})
    ):
        vol_spec = dict(row[vol_idx])
        vol_spec['data'] = dict(vol_spec.get('data', {}))
        vol_spec['data']['open'] = row[kline_idx]['data']['open']
        vol_spec['data']['close'] = row[kline_idx]['data']['close']
        row[vol_idx] = vol_spec
    specs_per_group = [row]
    group_titles = [','.join(grp)]
    return specs_per_group, types_info, group_titles


class TestChartTypeRegistry(unittest.TestCase):
    """注册表：get_applicable_types 返回类型及重要性."""

    def setUp(self):
        self.registry = get_chart_type_registry()
        print('\n[TestChartTypeRegistry] using default registry')

    def test_registry_ohlc_returns_kline_main(self):
        print('\n[P1-1] 注册表：仅 OHLC 返回 kline(main)，无 volume/macd')
        htypes = ['open', 'high', 'low', 'close']
        result = self.registry.get_applicable_types(htypes)
        ids = [t.id for t in result]
        self.assertIn('kline', ids)
        k = next(t for t in result if t.id == 'kline')
        self.assertEqual(k.importance, 'main')
        self.assertNotIn('volume', ids)
        self.assertNotIn('macd', ids)
        print('  applicable:', ids, 'kline importance:', k.importance)

    def test_registry_ohlc_vol_returns_kline_and_volume(self):
        print('\n[P1-2] 注册表：OHLC+vol 返回 kline(main)、volume(secondary)，顺序 kline 在前')
        htypes = ['open', 'high', 'low', 'close', 'vol']
        result = self.registry.get_applicable_types(htypes)
        ids = [t.id for t in result]
        self.assertIn('kline', ids)
        self.assertIn('volume', ids)
        self.assertLess(ids.index('kline'), ids.index('volume'))
        k = next(t for t in result if t.id == 'kline')
        v = next(t for t in result if t.id == 'volume')
        self.assertEqual(k.importance, 'main')
        self.assertEqual(v.importance, 'secondary')
        print('  order:', ids, 'kline=', k.importance, 'volume=', v.importance)

    def test_registry_only_open_high_returns_line_not_kline(self):
        print('\n[P1-3] 注册表：仅 open+high 不返回 kline，折线适用 open/high，importance main')
        htypes = ['open', 'high']
        result = self.registry.get_applicable_types(htypes)
        ids = [t.id for t in result]
        self.assertNotIn('kline', ids)
        self.assertIn('line', ids)
        line = next(t for t in result if t.id == 'line')
        self.assertEqual(line.importance, 'main')
        print('  applicable:', ids, 'line importance:', line.importance)

    def test_registry_only_close_returns_line_main(self):
        print('\n[P1-4] 注册表：仅 close 返回 line(main)，无 kline/volume/macd')
        htypes = ['close']
        result = self.registry.get_applicable_types(htypes)
        ids = [t.id for t in result]
        self.assertNotIn('kline', ids)
        self.assertNotIn('volume', ids)
        self.assertNotIn('macd', ids)
        self.assertIn('line', ids)
        line = next(t for t in result if t.id == 'line')
        self.assertEqual(line.importance, 'main')
        print('  applicable:', ids)

    def test_registry_ohlc_vol_macd_returns_three_types(self):
        print('\n[P1-5] 注册表：OHLC+vol+MACD 模式匹配返回 kline(main)、volume、macd(secondary)')
        htypes = [
            'open', 'high', 'low', 'close', 'vol',
            'macd_12_26_9', 'macd_signal_12_26_9', 'macd_hist_12_26_9',
        ]
        result = self.registry.get_applicable_types(htypes)
        ids = [t.id for t in result]
        self.assertIn('kline', ids)
        self.assertIn('volume', ids)
        self.assertIn('macd', ids)
        self.assertLess(ids.index('kline'), ids.index('volume'))
        self.assertLess(ids.index('volume'), ids.index('macd'))
        print('  applicable:', ids)


class TestKlineSpecBuilder(unittest.TestCase):
    """K 线规格生成器：OHLC 必选，不含 vol；仅 open+high 或缺 close 返回 None."""

    def test_kline_spec_ohlc_no_vol(self):
        print('\n[P1-6] K 线规格：OHLC 无 vol，片段不含成交量，chart_type=kline importance=main')
        hp = _make_hp(['open', 'high', 'low', 'close'])
        spec = build_kline_spec(hp)
        self.assertIsNotNone(spec)
        self.assertEqual(spec['chart_type'], 'kline')
        self.assertEqual(spec['importance'], 'main')
        self.assertNotIn('vol', spec.get('data', {}))
        self.assertNotIn('volume', spec.get('data', {}))
        self.assertIn('open', spec['data'])
        self.assertIn('close', spec['data'])
        print('  chart_type:', spec['chart_type'], 'keys:', list(spec['data'].keys()))

    def test_kline_spec_ohlc_vol_separate_volume_spec(self):
        print('\n[P1-7] K 线+成交量：K 线片段仅 OHLC，volume 由 build_volume_spec 单独返回')
        hp = _make_hp(['open', 'high', 'low', 'close', 'vol'])
        kspec = build_kline_spec(hp)
        vspec = build_volume_spec(hp)
        self.assertIsNotNone(kspec)
        self.assertIsNotNone(vspec)
        self.assertNotIn('vol', kspec['data'])
        self.assertNotIn('volume', kspec['data'])
        self.assertIn('vol', vspec['data'])
        print('  kline data keys:', list(kspec['data'].keys()), 'volume data keys:', list(vspec['data'].keys()))

    def test_kline_spec_only_open_high_returns_none(self):
        print('\n[P1-8] K 线规格：仅 open+high 不产出 K 线，返回 None')
        hp = _make_hp(['open', 'high'])
        spec = build_kline_spec(hp)
        self.assertIsNone(spec)
        line_spec = build_line_spec(hp)
        self.assertIsNotNone(line_spec)
        self.assertIn('open', line_spec['htypes_used'])
        self.assertIn('high', line_spec['htypes_used'])
        print('  kline_spec:', spec, 'line_spec htypes_used:', line_spec['htypes_used'])

    def test_kline_spec_missing_close_returns_none(self):
        print('\n[P1-9] K 线规格：缺 close 不产出 K 线')
        hp = _make_hp(['open', 'high', 'low'])
        spec = build_kline_spec(hp)
        self.assertIsNone(spec)
        print('  build_kline_spec(hp):', spec)


class TestVolumeSpecBuilder(unittest.TestCase):
    """成交量规格生成器."""

    def test_volume_spec_has_vol(self):
        print('\n[P1-10] 成交量规格：HP 含 vol 则返回 volume 片段，importance=secondary')
        hp = _make_hp(['close', 'vol'])
        spec = build_volume_spec(hp)
        self.assertIsNotNone(spec)
        self.assertEqual(spec['chart_type'], 'volume')
        self.assertEqual(spec['importance'], 'secondary')
        print('  chart_type:', spec['chart_type'], 'importance:', spec['importance'])

    def test_volume_spec_no_vol_returns_none(self):
        print('\n[P1-11] 成交量规格：HP 不含 vol/volume 返回 None')
        hp = _make_hp(['open', 'high', 'low', 'close'])
        spec = build_volume_spec(hp)
        self.assertIsNone(spec)
        print('  build_volume_spec(hp):', spec)


class TestMacdSpecBuilder(unittest.TestCase):
    """MACD 规格生成器：模式匹配三列，缺一列则 None."""

    def test_macd_spec_three_cols_ok(self):
        print('\n[P1-12] MACD 规格：与 macd() 输出一致的三列齐全则非 None，能区分柱/线')
        htypes = ['macd_12_26_9', 'macd_signal_12_26_9', 'macd_hist_12_26_9']
        hp = _make_hp(htypes)
        spec = build_macd_spec(hp)
        self.assertIsNotNone(spec)
        self.assertEqual(spec['chart_type'], 'macd')
        self.assertIn('series_kind', spec)
        for col in htypes:
            self.assertIn(col, spec['data'])
            self.assertIn(spec['series_kind'][col], ('line', 'bar'))
        print('  series_kind:', spec['series_kind'])

    def test_macd_spec_missing_one_returns_none(self):
        print('\n[P1-13] MACD 规格：仅两列则 None')
        hp = _make_hp(['macd_12_26_9', 'macd_signal_12_26_9'])
        spec = build_macd_spec(hp)
        self.assertIsNone(spec)
        print('  build_macd_spec(hp):', spec)


class TestLineSpecBuilder(unittest.TestCase):
    """折线规格：仅 close、open+high 等."""

    def test_line_spec_only_close(self):
        print('\n[P1-14] 折线规格：仅 close 可产出折线片段，无 K 线时 importance=main')
        hp = _make_hp(['close'])
        spec = build_line_spec(hp)
        self.assertIsNotNone(spec)
        self.assertIn('close', spec['htypes_used'])
        self.assertEqual(spec['importance'], 'main')
        print('  htypes_used:', spec['htypes_used'], 'importance:', spec['importance'])

    def test_line_spec_open_high_no_ohlc(self):
        print('\n[P1-15] 折线规格：仅 open+high 时折线覆盖两条线，K 线规格为 None')
        hp = _make_hp(['open', 'high'])
        kspec = build_kline_spec(hp)
        line_spec = build_line_spec(hp)
        self.assertIsNone(kspec)
        self.assertIsNotNone(line_spec)
        self.assertIn('open', line_spec['htypes_used'])
        self.assertIn('high', line_spec['htypes_used'])
        self.assertEqual(len(line_spec['data']), 2)
        print('  line_spec htypes_used:', line_spec['htypes_used'])


class TestQ01AdjOhlcKline(unittest.TestCase):
    """Q01：复权列名 open|b…close|b 下 K 线注册与规格；裸价与复权并存时优先裸价族。"""

    def setUp(self):
        self.registry = get_chart_type_registry()

    def test_q01_registry_back_adj_ohlc_returns_kline_volume_order(self):
        print('\n[Q01-1] 注册表：open|b…close|b+vol 适用 kline(main)、volume，顺序正确')
        htypes = ['open|b', 'high|b', 'low|b', 'close|b', 'vol']
        result = self.registry.get_applicable_types(htypes)
        ids = [t.id for t in result]
        self.assertIn('kline', ids)
        self.assertIn('volume', ids)
        self.assertLess(ids.index('kline'), ids.index('volume'))
        print('  applicable:', ids)

    def test_q01_kline_spec_back_adj_data_keys_and_values(self):
        print('\n[Q01-2] K 线规格：后复权四列 -> data 仍为 open…close 键，数值等于 HP 对应列')
        htypes = ['open|b', 'high|b', 'low|b', 'close|b']
        hp = _make_hp(htypes)
        spec = build_kline_spec(hp)
        self.assertIsNotNone(spec)
        self.assertEqual(spec['chart_type'], 'kline')
        for canon in ('open', 'high', 'low', 'close'):
            self.assertIn(canon, spec['data'])
        self.assertEqual(
            spec['htypes_used'],
            ['open|b', 'high|b', 'low|b', 'close|b'],
        )
        for canon, actual in zip(
            ('open', 'high', 'low', 'close'),
            ('open|b', 'high|b', 'low|b', 'close|b'),
        ):
            ci = hp.htypes.index(actual)
            np.testing.assert_array_almost_equal(
                spec['data'][canon],
                hp.values[:, :, ci].astype(float),
            )
        print('  htypes_used:', spec['htypes_used'])

    def test_q01_kline_prefers_bare_ohlc_when_both_suffix_and_bare_exist(self):
        print('\n[Q01-3] 裸 OHLC 与 |b 全套并存时，K 线选用裸名四列')
        htypes = ['open', 'high', 'low', 'close', 'open|b', 'high|b', 'low|b', 'close|b']
        hp = _make_hp(htypes)
        spec = build_kline_spec(hp)
        self.assertIsNotNone(spec)
        self.assertEqual(spec['htypes_used'], ['open', 'high', 'low', 'close'])
        ci_open = hp.htypes.index('open')
        np.testing.assert_array_almost_equal(
            spec['data']['open'],
            hp.values[:, :, ci_open].astype(float),
        )
        print('  selected htypes_used:', spec['htypes_used'])

    def test_q01_line_spec_excludes_kline_adj_columns(self):
        print('\n[Q01-4] 仅后复权四列时折线规格不包含 OHLC（避免与 K 线重复）')
        htypes = ['open|b', 'high|b', 'low|b', 'close|b']
        hp = _make_hp(htypes)
        self.assertIsNotNone(build_kline_spec(hp))
        line_spec = build_line_spec(hp)
        self.assertIsNone(line_spec)
        print('  build_line_spec:', line_spec)

    def test_q01_render_kline_back_adj_smoke(self):
        print('\n[Q01-5] 后复权 K 线规格 + 静态 render_kline_spec 烟测')
        hp = _make_hp(['open|b', 'high|b', 'low|b', 'close|b'])
        spec = build_kline_spec(hp)
        self.assertIsNotNone(spec)
        fig = render_kline_spec(spec)
        self.assertEqual(len(fig.axes), 1)
        print('  axes count:', len(fig.axes))


class TestSpecDataFromHpOnly(unittest.TestCase):
    """规格数据仅来自 HP 切片，无额外计算."""

    def test_spec_values_match_hp_values(self):
        print('\n[P1-16] 规格数据与 hp.values 对应切片一致')
        hp = _make_hp(['open', 'high', 'low', 'close'], n_shares=2, n_dates=10)
        kspec = build_kline_spec(hp)
        self.assertIsNotNone(kspec)
        for h in ['open', 'close']:
            col_idx = hp.htypes.index(h)
            expected = hp.values[:, :, col_idx].astype(float)
            np.testing.assert_array_almost_equal(kspec['data'][h], expected)
        print('  kline data open shape:', kspec['data']['open'].shape, 'hp.values slice shape:', hp.values[:, :, 0].shape)


class TestKlineOptionalMaBbands(unittest.TestCase):
    """K 线可选 ma/bbands 叠加；仅 ma 或仅 bbands 无 OHLC 不产出 K 线."""

    def test_kline_spec_ohlc_with_ma_or_bbands(self):
        print('\n[P1-17] K 线规格：OHLC+ma/bbands 可选叠加，htypes_used 含对应列名')
        hp = _make_hp(['open', 'high', 'low', 'close', 'sma_20'])
        spec = build_kline_spec(hp)
        self.assertIsNotNone(spec)
        self.assertIn('sma_20', spec['htypes_used'])
        self.assertIn('sma_20', spec['data'])
        hp2 = _make_hp(['open', 'high', 'low', 'close', 'bbands_upper_20_2_2', 'bbands_middle_20_2_2', 'bbands_lower_20_2_2'])
        spec2 = build_kline_spec(hp2)
        self.assertIsNotNone(spec2)
        for b in ['bbands_upper_20_2_2', 'bbands_middle_20_2_2', 'bbands_lower_20_2_2']:
            self.assertIn(b, spec2['htypes_used'])
        print('  kline with sma_20 htypes_used:', spec['htypes_used'])

    def test_kline_spec_only_ma_or_only_bbands_returns_none(self):
        print('\n[P1-18] 仅 ma 或仅 bbands 无 OHLC 不产出 K 线，折线可承接')
        hp_ma = _make_hp(['sma_20'])
        spec_ma = build_kline_spec(hp_ma)
        self.assertIsNone(spec_ma)
        line_ma = build_line_spec(hp_ma)
        self.assertIsNotNone(line_ma)
        self.assertIn('sma_20', line_ma['htypes_used'])
        hp_bb = _make_hp(['bbands_upper_20_2_2', 'bbands_middle_20_2_2', 'bbands_lower_20_2_2'])
        spec_bb = build_kline_spec(hp_bb)
        self.assertIsNone(spec_bb)
        line_bb = build_line_spec(hp_bb)
        self.assertIsNotNone(line_bb)
        self.assertEqual(len(line_bb['htypes_used']), 3)
        print('  only sma_20 -> kline:', spec_ma, 'line htypes:', line_ma['htypes_used'])


class TestStaticRender(unittest.TestCase):
    """Phase 2：静态渲染层 — 给定规格+数据→Figure，返回类型与轴数量符合预期。"""

    def test_render_kline_returns_figure_one_axes(self):
        print('\n[Phase2] 单类型单 share：K 线规格渲染返回 Figure，至少 1 个 axes')
        hp = _make_hp(['open', 'high', 'low', 'close'])
        spec = build_kline_spec(hp)
        self.assertIsNotNone(spec)
        fig = render_kline_spec(spec)
        self.assertIsNotNone(fig)
        self.assertGreaterEqual(len(fig.axes), 1)
        print('  figure type:', type(fig).__name__, 'n_axes:', len(fig.axes))

    def test_render_volume_returns_figure_one_axes(self):
        print('\n[Phase2] 成交量规格渲染返回 Figure，至少 1 个 axes')
        hp = _make_hp(['close', 'vol'])
        spec = build_volume_spec(hp)
        self.assertIsNotNone(spec)
        fig = render_volume_spec(spec)
        self.assertIsNotNone(fig)
        self.assertGreaterEqual(len(fig.axes), 1)
        print('  n_axes:', len(fig.axes))

    def test_render_macd_returns_figure_one_axes(self):
        print('\n[Phase2] MACD 规格渲染返回 Figure，至少 1 个 axes')
        hp = _make_hp(['macd_12_26_9', 'macd_signal_12_26_9', 'macd_hist_12_26_9'])
        spec = build_macd_spec(hp)
        self.assertIsNotNone(spec)
        fig = render_macd_spec(spec)
        self.assertIsNotNone(fig)
        self.assertGreaterEqual(len(fig.axes), 1)
        print('  n_axes:', len(fig.axes))

    def test_render_line_returns_figure_one_axes(self):
        print('\n[Phase2] 折线规格渲染返回 Figure，至少 1 个 axes')
        hp = _make_hp(['close'])
        spec = build_line_spec(hp)
        self.assertIsNotNone(spec)
        fig = render_line_spec(spec)
        self.assertIsNotNone(fig)
        self.assertGreaterEqual(len(fig.axes), 1)
        print('  n_axes:', len(fig.axes))

    def test_render_spec_dispatches_by_chart_type(self):
        print('\n[Phase2] render_spec 按 chart_type 分发，返回 Figure')
        hp = _make_hp(['open', 'high', 'low', 'close'])
        spec = build_kline_spec(hp)
        fig = render_spec(spec)
        self.assertIsNotNone(fig)
        self.assertGreaterEqual(len(fig.axes), 1)
        hp2 = _make_hp(['close'])
        spec2 = build_line_spec(hp2)
        fig2 = render_spec(spec2)
        self.assertIsNotNone(fig2)
        print('  kline fig axes:', len(fig.axes), 'line fig axes:', len(fig2.axes))


class TestPlotOrchestrator(unittest.TestCase):
    """Phase 3：hp.plot() 编排器 — 1/2/多 shares、分组与组内子图数量."""

    def test_plot_one_share_returns_figure(self):
        print('\n[Phase3] 1 share：hp.plot() 返回 Figure，组内子图数量=适用图表类型数')
        hp = _make_hp(['open', 'high', 'low', 'close'], n_shares=1)
        fig = hp.plot()
        self.assertIsNotNone(fig)
        self.assertGreaterEqual(len(fig.axes), 1)
        print('  n_axes:', len(fig.axes))

    def test_plot_only_close_returns_one_subplot(self):
        print('\n[Phase3] 仅 close：仅 line 类型，1 个子图')
        hp = _make_hp(['close'], n_shares=1)
        fig = hp.plot()
        self.assertIsNotNone(fig)
        self.assertEqual(len(fig.axes), 1)
        print('  n_axes:', len(fig.axes))

    def test_plot_ohlc_vol_two_subplots(self):
        print('\n[Phase3] OHLC+vol：kline+volume，2 个子图')
        hp = _make_hp(['open', 'high', 'low', 'close', 'vol'], n_shares=1)
        fig = hp.plot()
        self.assertIsNotNone(fig)
        self.assertGreaterEqual(len(fig.axes), 2)
        print('  n_axes:', len(fig.axes))

    def test_plot_two_shares_stack_two_groups(self):
        print('\n[Phase3] 2 shares layout=stack：2 组，每组子图数=类型数')
        hp = _make_hp(['open', 'high', 'low', 'close'], n_shares=2)
        fig = hp.plot(layout='stack')
        self.assertIsNotNone(fig)
        self.assertGreaterEqual(len(fig.axes), 1)
        print('  n_axes:', len(fig.axes))


class TestPhase7PlotPaging(unittest.TestCase):
    """Phase7：plot 多标的分页（不再静默只画前 5 个）。"""

    def test_phase7_plot_paging_stack_titles_match_selected_shares(self):
        print('\n[Phase7] plot paging: stack titles match selected shares')
        hp = _make_hp(['close'], n_shares=7, n_dates=20)
        # page=1 -> S000,S001,S002
        fig1 = hp.plot(layout='stack', max_shares_per_figure=3, page=1)
        titles1 = [ax.get_title() for ax in fig1.axes]
        print('  titles page1:', titles1)
        self.assertEqual(len(titles1), 3)
        self.assertTrue(all(t.startswith('S000') or t.startswith('S001') or t.startswith('S002') for t in titles1))
        self.assertTrue(titles1[0].startswith('S000'))
        self.assertTrue(titles1[1].startswith('S001'))
        self.assertTrue(titles1[2].startswith('S002'))

        # page=2 -> S003,S004,S005
        fig2 = hp.plot(layout='stack', max_shares_per_figure=3, page=2)
        titles2 = [ax.get_title() for ax in fig2.axes]
        print('  titles page2:', titles2)
        self.assertEqual(len(titles2), 3)
        self.assertTrue(titles2[0].startswith('S003'))
        self.assertTrue(titles2[1].startswith('S004'))
        self.assertTrue(titles2[2].startswith('S005'))

        # page=3 -> S006
        fig3 = hp.plot(layout='stack', max_shares_per_figure=3, page=3)
        titles3 = [ax.get_title() for ax in fig3.axes]
        print('  titles page3:', titles3)
        self.assertEqual(len(titles3), 1)
        self.assertTrue(titles3[0].startswith('S006'))

    def test_phase7_plot_paging_page_out_of_range_raises(self):
        print('\n[Phase7] plot paging: page out of range raises')
        hp = _make_hp(['close'], n_shares=7, n_dates=20)
        with self.assertRaises(ValueError) as cm:
            hp.plot(layout='stack', max_shares_per_figure=3, page=4)
        print('  error:', cm.exception)
        self.assertIn('page', str(cm.exception).lower())

    def test_phase7_plot_paging_invalid_max_shares_raises(self):
        print('\n[Phase7] plot paging: invalid max_shares_per_figure raises')
        hp = _make_hp(['close'], n_shares=2, n_dates=10)
        with self.assertRaises(ValueError):
            hp.plot(layout='stack', max_shares_per_figure=0, page=1)


class TestHighlight(unittest.TestCase):
    """Phase 4：highlight 子模块 — condition + style，接入 hp.plot(..., highlight=...). 对应计划 8.7."""

    def test_p4_1_highlight_none_no_error(self):
        print('\n[P4-1] highlight=None 不报错，正常出图')
        hp = _make_hp(['close'], n_shares=1)
        fig = hp.plot(highlight=None)
        self.assertIsNotNone(fig)
        self.assertEqual(len(fig.axes), 1)
        ax = fig.axes[0]
        self.assertEqual(len(ax.collections), 0)
        print('  n_axes:', len(fig.axes), 'n_collections:', len(ax.collections))

    def test_p4_2_highlight_max_string_line(self):
        print('\n[P4-2] highlight=\"max\" 折线图，仅 close 最大值点高亮')
        hp = _make_hp(['close'], n_shares=1)
        fig = hp.plot(highlight='max')
        self.assertIsNotNone(fig)
        ax = fig.axes[0]
        collections = [c for c in ax.collections if hasattr(c, 'get_sizes')]
        self.assertGreaterEqual(len(collections), 1)
        print('  n_collections:', len(collections))

    def test_p4_3_highlight_condition_min_line(self):
        print('\n[P4-3] highlight={\"condition\": \"min\"} 折线图，最小值点高亮')
        hp = _make_hp(['close'], n_shares=1)
        spec = build_line_spec(hp)
        self.assertIsNotNone(spec)
        spec = dict(spec)
        spec['highlight'] = {'condition': 'min'}
        fig = render_line_spec(spec)
        self.assertIsNotNone(fig)
        ax = fig.axes[0]
        self.assertGreaterEqual(len(ax.collections), 1)
        print('  n_collections:', len(ax.collections))

    def test_p4_4_highlight_with_style(self):
        print('\n[P4-4] highlight 含 style，高亮点颜色与大小与 style 一致')
        hp = _make_hp(['close'], n_shares=1, n_dates=10)
        spec = build_line_spec(hp)
        spec = dict(spec)
        spec['highlight'] = {'condition': 'max', 'style': {'color': 'red', 's': 60}}
        fig = render_line_spec(spec)
        self.assertIsNotNone(fig)
        ax = fig.axes[0]
        self.assertGreaterEqual(len(ax.collections), 1)
        coll = ax.collections[0]
        self.assertEqual(coll.get_facecolor().shape[0], 1)
        self.assertAlmostEqual(coll.get_sizes()[0], 60)
        print('  collection sizes:', coll.get_sizes(), 'facecolor:', coll.get_facecolor())

    def test_p4_5_highlight_condition_bool_array(self):
        print('\n[P4-5] condition 为布尔数组，仅 True 处高亮')
        hp = _make_hp(['close'], n_shares=1, n_dates=15)
        spec = build_line_spec(hp)
        self.assertIsNotNone(spec)
        spec = dict(spec)
        mask = np.zeros(15, dtype=bool)
        mask[7] = True
        spec['highlight'] = {'condition': mask}
        fig = render_line_spec(spec)
        self.assertIsNotNone(fig)
        ax = fig.axes[0]
        self.assertGreaterEqual(len(ax.collections), 1)
        offs = ax.collections[0].get_offsets()
        self.assertEqual(offs.shape[0], 1)
        self.assertEqual(int(offs[0, 0]), 7)
        print('  single scatter at index:', int(offs[0, 0]))

    def test_p4_6_multi_group_stack_highlight(self):
        print('\n[P4-6] 多组 stack 时 highlight=\"max\"，每组折线各自 close 最大值高亮')
        hp = _make_hp(['close'], n_shares=2, n_dates=20)
        fig = hp.plot(layout='stack', highlight='max')
        self.assertIsNotNone(fig)
        self.assertEqual(len(fig.axes), 2)
        for i, ax in enumerate(fig.axes):
            collections = [c for c in ax.collections if hasattr(c, 'get_sizes')]
            self.assertGreaterEqual(len(collections), 1, f'group {i} should have highlight scatter')
        print('  n_axes:', len(fig.axes), 'each has scatter')

    def test_plot_with_highlight_max_dict(self):
        print('\n[Phase4] hp.plot(..., highlight={\"condition\": \"max\"}) 不报错，返回 Figure')
        hp = _make_hp(['close'], n_shares=1)
        fig = hp.plot(highlight={'condition': 'max'})
        self.assertIsNotNone(fig)
        self.assertEqual(len(fig.axes), 1)
        ax = fig.axes[0]
        collections = [c for c in ax.collections if hasattr(c, 'get_sizes')]
        self.assertGreaterEqual(len(collections), 1)
        print('  n_axes:', len(fig.axes), 'n_collections:', len(ax.collections))

    def test_render_line_spec_with_highlight_min(self):
        print('\n[Phase4] render_line_spec(spec with highlight condition=min) 绘制高亮点')
        hp = _make_hp(['close'], n_shares=1)
        spec = build_line_spec(hp)
        self.assertIsNotNone(spec)
        spec = dict(spec)
        spec['highlight'] = {'condition': 'min', 'style': {'color': 'red', 'marker': 'o'}}
        fig = render_line_spec(spec)
        self.assertIsNotNone(fig)
        ax = fig.axes[0]
        self.assertGreaterEqual(len(ax.collections), 1)
        print('  n_collections:', len(ax.collections))


class TestInteractiveBackend(unittest.TestCase):
    """Phase 5：hp.plot(..., interactive=True) 返回 Plotly Figure，结构可用."""

    def test_plot_interactive_true_returns_figure(self):
        print('\n[Phase5] hp.plot(interactive=True) 返回 Figure，结构可用')
        hp = _make_hp(['close'], n_shares=1)
        try:
            fig = hp.plot(interactive=True)
        except RuntimeError as e:
            if 'plotly' in str(e).lower():
                self.skipTest('plotly not installed, skip interactive test')
            raise
        self.assertIsNotNone(fig)
        if hasattr(fig, 'data'):
            self.assertGreaterEqual(len(fig.data), 1, 'Plotly figure should have at least one trace')
            print('  plotly fig.data traces:', len(fig.data))
        else:
            self.assertGreaterEqual(len(fig.axes), 1)
            print('  n_axes:', len(fig.axes))

    def test_plot_interactive_two_shares_stack(self):
        print('\n[Phase5] 2 shares layout=stack interactive 返回多 trace、多组')
        hp = _make_hp(['close'], n_shares=2)
        try:
            fig = hp.plot(interactive=True, layout='stack')
        except RuntimeError as e:
            if 'plotly' in str(e).lower():
                self.skipTest('plotly not installed')
            raise
        self.assertIsNotNone(fig)
        if hasattr(fig, 'data'):
            self.assertGreaterEqual(len(fig.data), 2)
            print('  plotly traces:', len(fig.data))

    def test_plotly_backend_app_invalid_raises(self):
        print('\n[Phase5] plotly_backend_app 非法取值抛 ValueError')
        hp = _make_hp(['close'], n_shares=1)
        try:
            import plotly  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')
        with self.assertRaises(ValueError) as ctx:
            hp.plot(interactive=True, plotly_backend_app='widget')
        self.assertIn('plotly_backend_app', str(ctx.exception).lower())
        print('  message:', ctx.exception)

    def test_plotly_backend_app_requires_interactive(self):
        print('\n[Phase5] plotly_backend_app 非 auto 时必须 interactive=True')
        hp = _make_hp(['close'], n_shares=1)
        with self.assertRaises(ValueError) as ctx:
            hp.plot(interactive=False, plotly_backend_app='html')
        self.assertIn('interactive', str(ctx.exception).lower())
        print('  message:', ctx.exception)

    def test_normalize_plotly_backend_app_aliases(self):
        print('\n[Phase5] _normalize_plotly_backend_app 大小写与 auto')
        from qteasy.hp_visual_plotly import _normalize_plotly_backend_app

        self.assertEqual(_normalize_plotly_backend_app('auto'), 'auto')
        self.assertEqual(_normalize_plotly_backend_app('FigureWidget'), 'figurewidget')
        self.assertEqual(_normalize_plotly_backend_app('HTML'), 'html')


class TestQ08PlotlyBackendAppNonNotebookContracts(unittest.TestCase):
    """Q08：非 Notebook 环境下 plotly_backend_app 的回退/报错契约。"""

    def test_q08_select_plotly_notebook_output_non_kernel_contract(self):
        print('\n[Q08] _select_plotly_notebook_output：非 Notebook 下 auto 返回原 Figure；html/FigureWidget 抛错')
        try:
            import plotly.graph_objects as go  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')

        from qteasy.hp_visual_plotly import _in_ipython_kernel, _select_plotly_notebook_output

        if _in_ipython_kernel():
            self.skipTest('running inside IPython/Jupyter kernel, non-notebook contract not applicable')

        fig = go.Figure()
        out = _select_plotly_notebook_output(fig, plotly_backend_app='auto')
        self.assertIs(out, fig)
        print('  auto -> returned original figure:', out is fig)

        with self.assertRaises(RuntimeError) as ctx1:
            _select_plotly_notebook_output(fig, plotly_backend_app='html')
        self.assertIn('requires an active jupyter', str(ctx1.exception).lower())
        print('  html error:', ctx1.exception)

        with self.assertRaises(RuntimeError) as ctx2:
            _select_plotly_notebook_output(fig, plotly_backend_app='FigureWidget')
        self.assertIn('requires an active jupyter', str(ctx2.exception).lower())
        print('  FigureWidget error:', ctx2.exception)


class TestQ08OverlayTraceVisualApply(unittest.TestCase):
    """Q08：overlay 下 trace 视觉更新函数的纯逻辑契约（无需 Plotly 真对象）。"""

    def test_q08_overlay_apply_trace_visual_primary_secondary_and_highlight(self):
        print('\n[Q08] overlay trace visual：主次透明度/线宽 + 高亮点 primary-only')
        from qteasy.hp_visual_plotly import _hp_plotly_overlay_apply_trace_visual

        class _Line:
            def __init__(self) -> None:
                self.width = 0.0

        class _IncDec:
            def __init__(self) -> None:
                self.line = _Line()

        class _Trace:
            def __init__(self, typ: str, name: str) -> None:
                self.type = typ
                self.name = name
                self.opacity = None
                self.line = _Line() if typ == 'scatter' else None
                self.increasing = _IncDec() if typ == 'candlestick' else None
                self.decreasing = _IncDec() if typ == 'candlestick' else None

        meta = {
            'overlay_primary_opacity': 1.0,
            'overlay_secondary_opacity': 0.15,
            'overlay_pri_scatter_lw': 3.0,
            'overlay_sec_scatter_lw': 1.0,
            'overlay_pri_candle_inc': 2.0,
            'overlay_sec_candle_inc': 1.0,
            'overlay_pri_candle_dec': 2.0,
            'overlay_sec_candle_dec': 1.0,
        }

        # scatter 主/次
        t_pri = _Trace('scatter', 'S0-close')
        t_sec = _Trace('scatter', 'S1-close')
        _hp_plotly_overlay_apply_trace_visual(t_pri, s0=0, primary_share=0, meta_now=meta)
        _hp_plotly_overlay_apply_trace_visual(t_sec, s0=1, primary_share=0, meta_now=meta)
        self.assertEqual(float(t_pri.opacity), 1.0)
        self.assertEqual(float(t_sec.opacity), 0.15)
        self.assertEqual(float(t_pri.line.width), 3.0)
        self.assertEqual(float(t_sec.line.width), 1.0)
        print('  scatter op/lw pri/sec:', t_pri.opacity, t_pri.line.width, '/', t_sec.opacity, t_sec.line.width)

        # candlestick 主/次
        c_pri = _Trace('candlestick', 'S0-candle')
        c_sec = _Trace('candlestick', 'S1-candle')
        _hp_plotly_overlay_apply_trace_visual(c_pri, s0=0, primary_share=0, meta_now=meta)
        _hp_plotly_overlay_apply_trace_visual(c_sec, s0=1, primary_share=0, meta_now=meta)
        self.assertEqual(float(c_pri.opacity), 1.0)
        self.assertEqual(float(c_sec.opacity), 0.15)
        self.assertEqual(float(c_pri.increasing.line.width), 2.0)
        self.assertEqual(float(c_sec.increasing.line.width), 1.0)
        print('  candle op/inc_w pri/sec:', c_pri.opacity, c_pri.increasing.line.width, '/', c_sec.opacity, c_sec.increasing.line.width)

        # highlight：次标的隐藏
        h_pri = _Trace('scatter', 'Highlight[S0]-max')
        h_sec = _Trace('scatter', 'Highlight[S1]-max')
        _hp_plotly_overlay_apply_trace_visual(h_pri, s0=0, primary_share=0, meta_now=meta)
        _hp_plotly_overlay_apply_trace_visual(h_sec, s0=1, primary_share=0, meta_now=meta)
        self.assertEqual(float(h_pri.opacity), 1.0)
        self.assertEqual(float(h_sec.opacity), 0.0)
        print('  highlight op pri/sec:', h_pri.opacity, h_sec.opacity)


class TestQ08HtmlWrapperHighlightAndCrosshairContract(unittest.TestCase):
    """Q08：HTML wrapper 同屏 contract（overlay + highlight + crosshair 的关键 token）。"""

    def test_q08_html_contains_overlay_highlight_crosshair_tokens(self):
        print('\n[Q08] HTML wrapper：overlay+highlight+crosshair token contract')
        try:
            import plotly  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')

        from qteasy.hp_visual_plotly import _PlotlyFigureWrapper

        hp = _make_hp(['open', 'high', 'low', 'close', 'vol'], n_shares=2, n_dates=28)
        fig = hp.plot(interactive=True, layout='overlay', highlight='max', plotly_backend_app='auto')
        base = fig.figure if hasattr(fig, 'figure') else fig
        html_str = _PlotlyFigureWrapper(base)._repr_html_()

        # overlay 主次切换相关 token
        self.assertIn('overlay_active_share_by_group', html_str)
        self.assertIn('bar_data_by_share', html_str)

        # crosshair token
        self.assertIn('_hp_crosshair_selected', html_str)

        # highlight token（trace name 序列化）
        self.assertIn('Highlight', html_str)

        # x/y autorange 脚本 token（Q03 合同）
        self.assertIn('_hpXClampBusy', html_str)
        self.assertIn('normalizeXViewRange', html_str)

        print('  html length:', len(html_str))


class TestQ06PlotlyHighlight(unittest.TestCase):
    """Q06：Plotly 下 highlight 生效（line + kline），overlay 默认仅主 share 显示高亮点。"""

    def _skip_if_no_plotly(self) -> None:
        try:
            import plotly  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')

    def _get_plotly_figure(self, fig: Any) -> Any:
        """兼容 _PlotlyFigureWrapper / FigureWidget / Figure。"""
        if hasattr(fig, 'figure'):
            return fig.figure
        return fig

    def test_q06_line_highlight_markers_exist(self):
        print('\n[Q06] interactive line + highlight=\"max\"：应出现 markers 高亮点 trace')
        self._skip_if_no_plotly()
        hp = _make_hp(['close'], n_shares=1, n_dates=20)
        fig = hp.plot(interactive=True, highlight='max')
        pf = self._get_plotly_figure(fig)
        self.assertTrue(hasattr(pf, 'data'))
        markers = [
            tr for tr in pf.data
            if str(getattr(tr, 'type', '')).lower() == 'scatter'
            and 'markers' in str(getattr(tr, 'mode', '')).lower()
            and 'highlight' in str(getattr(tr, 'name', '')).lower()
        ]
        print('  total traces:', len(pf.data), 'highlight markers traces:', len(markers))
        for tr in markers[:5]:
            print('   - name:', getattr(tr, 'name', ''), 'opacity:', getattr(tr, 'opacity', None))
        self.assertGreaterEqual(len(markers), 1)

    def test_q06_kline_highlight_markers_exist(self):
        print('\n[Q06] interactive kline + highlight=\"max\"：应出现 markers 高亮点 trace（基于 close）')
        self._skip_if_no_plotly()
        hp = _make_hp(['open', 'high', 'low', 'close'], n_shares=1, n_dates=30)
        fig = hp.plot(interactive=True, highlight='max')
        pf = self._get_plotly_figure(fig)
        markers = [
            tr for tr in pf.data
            if str(getattr(tr, 'type', '')).lower() == 'scatter'
            and 'markers' in str(getattr(tr, 'mode', '')).lower()
            and 'highlight' in str(getattr(tr, 'name', '')).lower()
        ]
        print('  total traces:', len(pf.data), 'highlight markers traces:', len(markers))
        self.assertGreaterEqual(len(markers), 1)

    def test_q06_overlay_primary_only_highlight_visible(self):
        print('\n[Q06] overlay(2 shares) + highlight=\"max\"：应存在两套高亮点，但默认仅主 share 可见')
        self._skip_if_no_plotly()
        hp = _make_hp(['close'], n_shares=2, n_dates=25)
        fig = hp.plot(interactive=True, layout='overlay', highlight='max')
        pf = self._get_plotly_figure(fig)
        markers = [
            tr for tr in pf.data
            if str(getattr(tr, 'type', '')).lower() == 'scatter'
            and 'markers' in str(getattr(tr, 'mode', '')).lower()
            and 'highlight' in str(getattr(tr, 'name', '')).lower()
        ]
        print('  highlight markers traces:', len(markers))
        for tr in markers:
            print('   -', getattr(tr, 'name', ''), 'opacity:', getattr(tr, 'opacity', None))
        self.assertGreaterEqual(len(markers), 2)
        opacities = [float(getattr(tr, 'opacity', 1.0) or 0.0) for tr in markers]
        self.assertGreaterEqual(max(opacities), 0.9)
        self.assertLessEqual(min(opacities), 0.05)

    def test_q06_overlay_kline_highlight_secondary_hidden(self):
        print('\n[Q06] overlay(2 shares) K 线 + highlight=\"max\"：两套高亮点，默认仅主 share 可见')
        self._skip_if_no_plotly()
        hp = _make_hp(['open', 'high', 'low', 'close'], n_shares=2, n_dates=22)
        fig = hp.plot(interactive=True, layout='overlay', highlight='max')
        pf = self._get_plotly_figure(fig)
        markers = [
            tr for tr in pf.data
            if str(getattr(tr, 'type', '')).lower() == 'scatter'
            and 'markers' in str(getattr(tr, 'mode', '')).lower()
            and 'highlight' in str(getattr(tr, 'name', '')).lower()
        ]
        print('  highlight markers traces:', len(markers))
        for tr in markers:
            print('   -', getattr(tr, 'name', ''), 'opacity:', getattr(tr, 'opacity', None))
        self.assertGreaterEqual(len(markers), 2)
        opacities = [float(getattr(tr, 'opacity', 1.0) or 0.0) for tr in markers]
        self.assertGreaterEqual(max(opacities), 0.9)
        self.assertLessEqual(min(opacities), 0.05)


class TestQ02SubplotTitlesHtmlExport(unittest.TestCase):
    """Q02：Notebook HTML 路径保留 subplot 分组标题，与 FigureWidget 的 annotation 前缀语义一致。"""

    def test_q02_html_export_preserves_subplot_title_annotations(self):
        print('\n[Q02] 双组 stack+OHLC：subplot_annotation_count 与 HTML 导出保留分组标题')
        try:
            import plotly.graph_objects as go  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')
        from qteasy.hp_visual_plotly import (
            _PlotlyFigureWrapper,
            _annotations_for_plotly_html_export,
            build_interactive_figure_from_specs,
        )

        hp = _make_hp(['open', 'high', 'low', 'close', 'vol'], n_shares=2, n_dates=12)
        specs_per_group, types_info, group_titles = _specs_per_group_stack_two_shares(hp)
        x_dates = list(hp.hdates)
        fig = build_interactive_figure_from_specs(
            specs_per_group,
            types_info,
            x_dates=x_dates,
            group_titles=group_titles,
        )
        meta = getattr(fig, '_hp_plotly_meta', {})
        self.assertTrue(meta.get('show_ohlc_header'))
        n_sub = int(meta.get('subplot_annotation_count', 0))
        self.assertGreater(n_sub, 0, 'stack two groups should have subplot title annotations')
        ann_full = list(fig.layout.annotations) if fig.layout.annotations else []
        self.assertEqual(len(ann_full), n_sub)
        print('  subplot_annotation_count:', n_sub, 'len(layout.annotations):', len(ann_full))

        kept = _annotations_for_plotly_html_export(fig.layout, meta)
        self.assertEqual(len(kept), n_sub)
        title_blob = ' '.join(str(getattr(a, 'text', '') or '') for a in kept)
        self.assertIn('S000', title_blob)
        self.assertIn('S001', title_blob)
        print('  kept annotation texts contain share codes:', 'S000' in title_blob, 'S001' in title_blob)

        wrapper = _PlotlyFigureWrapper(fig)
        html_str = wrapper._repr_html_()
        self.assertGreater(len(html_str), 100)
        # 分组标题应出现在序列化后的 Plotly 数据中（不仅依赖 trace name_prefix）
        self.assertIn('"S000"', html_str)
        self.assertIn('"S001"', html_str)
        print('  _repr_html_ contains quoted S000/S001 in payload')


class TestQ03NormalizeXViewRange(unittest.TestCase):
    """Q03：x 视图归一化、pan 平移越界、短序列放大限制；覆盖原需 Notebook 验收的 shared_x 主从轴与全轴 relayout。"""

    def test_q03_expand_narrow_window_to_min_visible(self):
        print('\n[Q03] n=100 过窄窗口扩至至少 15 根，中心近似保持')
        from qteasy.hp_visual_plotly import HP_PLOTLY_MIN_VISIBLE_BARS, _hp_plotly_normalize_x_view_range

        n = 100
        x0, x1, j0, j1 = _hp_plotly_normalize_x_view_range(n, 50.0, 50.2)
        self.assertGreaterEqual(j1 - j0 + 1, HP_PLOTLY_MIN_VISIBLE_BARS)
        center_in = 0.5 * (50.0 + 50.2)
        center_out = 0.5 * (x0 + x1)
        self.assertLess(abs(center_out - center_in), 2.0)
        print('  in center', center_in, 'out', center_out, 'j0,j1', j0, j1)

    def test_q03_small_n_zoom_clamped_to_full_span(self):
        print('\n[Q03] n<15 时禁止放大到窄于全部 bar（effective=min(15,n)=n）')
        from qteasy.hp_visual_plotly import _hp_plotly_normalize_x_view_range

        x0, x1, j0, j1 = _hp_plotly_normalize_x_view_range(10, 2.0, 3.0)
        self.assertEqual(j0, 0)
        self.assertEqual(j1, 9)
        self.assertEqual(j1 - j0 + 1, 10)
        self.assertAlmostEqual(x0, 0.0, places=6)
        self.assertAlmostEqual(x1, 9.0, places=6)
        print('  (2,3) -> full span j0,j1', j0, j1)

    def test_q03_swap_and_translate_edges(self):
        print('\n[Q03] x0>x1 交换；越界时平移保持视窗宽度（非两端独立 clamp）')
        from qteasy.hp_visual_plotly import _hp_plotly_normalize_x_view_range

        x0, x1, j0, j1 = _hp_plotly_normalize_x_view_range(100, 80.0, 20.0)
        self.assertLessEqual(x0, x1)
        self.assertGreaterEqual(x0, 0.0)
        self.assertLessEqual(x1, 99.0)
        print('  80,20 ->', x0, x1, j0, j1)

        x0b, x1b, _, _ = _hp_plotly_normalize_x_view_range(100, -20.0, 5.0)
        self.assertEqual(x0b, 0.0)
        self.assertAlmostEqual(x1b, 25.0, places=10)
        print('  -20,5 ->', x0b, x1b, '(span 25 保留)')

    def test_q03_pan_overflow_preserves_span(self):
        print('\n[Q03] 仅 pan 越界：保持窗口宽度平移回 [0,n-1]')
        from qteasy.hp_visual_plotly import _hp_plotly_normalize_x_view_range

        n = 89
        max_x = float(n - 1)
        x0, x1, _, _ = _hp_plotly_normalize_x_view_range(
            n, -44.285714285714285, 43.71428571428571
        )
        self.assertAlmostEqual(x0, 0.0, places=6)
        self.assertAlmostEqual(x1, max_x, places=10)
        self.assertAlmostEqual(x1 - x0, max_x, places=10)
        print('  视窗跨度等于全长 -> 占满 [0,max_x]', x0, x1)

        x0b, x1b, _, _ = _hp_plotly_normalize_x_view_range(n, -20.0, 50.0)
        self.assertAlmostEqual(x0b, 0.0, places=10)
        self.assertAlmostEqual(x1b, 70.0, places=10)
        self.assertAlmostEqual(x1b - x0b, 70.0, places=10)
        print('  pan (-20,50) -> (0,70) 宽度 70 保留')

    def test_q03_coincident_ends_expand_one(self):
        print('\n[Q03] x0==x1 先扩 1 单位；n 足够大时再按最少 15 根扩窗')
        from qteasy.hp_visual_plotly import HP_PLOTLY_MIN_VISIBLE_BARS, _hp_plotly_normalize_x_view_range

        x0, x1, j0, j1 = _hp_plotly_normalize_x_view_range(100, 50.0, 50.0)
        self.assertGreater(x1, x0)
        self.assertGreaterEqual(j1 - j0 + 1, HP_PLOTLY_MIN_VISIBLE_BARS)
        print('  n=100 x0==x1 ->', x0, x1, 'j0,j1', j0, j1)

        x0s, x1s, j0s, j1s = _hp_plotly_normalize_x_view_range(12, 5.0, 5.0)
        self.assertEqual(j0s, 0)
        self.assertEqual(j1s, 11)
        self.assertEqual(j1s - j0s + 1, 12)
        print('  n=12 x0==x1 -> full span', x0s, x1s, 'j0,j1', j0s, j1s)

    def test_q03_y_autorange_script_contract(self):
        print('\n[Q03] _y_autorange_script 含 normalize、_hpXClampBusy、xaxis.range、relayout、min_visible_bars')
        from qteasy.hp_visual_plotly import HP_PLOTLY_MIN_VISIBLE_BARS, _y_autorange_script

        s = _y_autorange_script('dummy-div')
        self.assertIn('normalizeXViewRange', s)
        self.assertIn('effMvb', s)
        self.assertIn('Math.min(mvb, n)', s)
        self.assertIn('effMvb >= n', s)
        self.assertIn('_hpXClampBusy', s)
        self.assertIn('xaxis.range', s)
        self.assertIn('Plotly.relayout', s)
        self.assertIn('min_visible_bars', s)
        self.assertIn(str(int(HP_PLOTLY_MIN_VISIBLE_BARS)), s)
        print('  script length', len(s))

    def test_q03_meta_carries_min_visible_and_subplot_rows(self):
        print('\n[Q03] build_interactive_figure_from_specs 的 meta 含 min_visible_bars、plotly_n_subplot_rows')
        try:
            import plotly.graph_objects as go  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')
        from qteasy.hp_visual_plotly import (
            HP_PLOTLY_MIN_VISIBLE_BARS,
            build_interactive_figure_from_specs,
        )

        hp = _make_hp(['open', 'high', 'low', 'close', 'vol'], n_shares=1, n_dates=20)
        registry = get_chart_type_registry()
        types_info = registry.get_applicable_types(hp.htypes)
        specs = []
        for info in types_info:
            if info.id == 'kline':
                specs.append(build_kline_spec(hp))
            elif info.id == 'volume':
                specs.append(build_volume_spec(hp))
            else:
                specs.append(build_line_spec(hp))
        specs_per_group = [specs]
        fig = build_interactive_figure_from_specs(
            specs_per_group,
            types_info,
            x_dates=list(hp.hdates),
            group_titles=['T'],
        )
        meta = getattr(fig, '_hp_plotly_meta', {})
        self.assertEqual(meta.get('min_visible_bars'), HP_PLOTLY_MIN_VISIBLE_BARS)
        self.assertGreater(int(meta.get('plotly_n_subplot_rows', 0)), 0)
        print('  min_visible_bars', meta.get('min_visible_bars'), 'plotly_n_subplot_rows', meta.get('plotly_n_subplot_rows'))

    def test_q03_plotly_master_xaxis_detection_shared_subplots(self):
        print('\n[Q03] make_subplots shared_xaxes：主 x 为 xaxis3（matches=None）')
        try:
            import plotly.graph_objects as go
            from plotly.subplots import make_subplots
        except ImportError:
            self.skipTest('plotly not installed')
        from qteasy.hp_visual_plotly import (
            _hp_plotly_layout_xaxis_names,
            _hp_plotly_master_xaxis_names,
            _hp_plotly_x_range_layout_updates,
        )

        fig = make_subplots(rows=3, cols=1, shared_xaxes=True)
        fig.add_trace(go.Scatter(x=[0, 1, 2], y=[1, 2, 3]), row=1, col=1)
        fig.add_trace(go.Bar(x=[0, 1, 2], y=[3, 4, 5]), row=2, col=1)
        fig.add_trace(go.Scatter(x=[0, 1, 2], y=[6, 7, 8]), row=3, col=1)
        names = _hp_plotly_layout_xaxis_names(fig.layout)
        masters = _hp_plotly_master_xaxis_names(fig.layout)
        self.assertIn('xaxis', names)
        self.assertIn('xaxis2', names)
        self.assertIn('xaxis3', names)
        self.assertEqual(masters, ['xaxis3'])
        want_r = [1.0, 9.0]
        upd = _hp_plotly_x_range_layout_updates(want_r[0], want_r[1], names)
        self.assertEqual(len(upd), len(names), 'each xaxis must get a relayout key (Notebook 半边空白曾源于只写部分轴)')
        for nm in names:
            key = 'xaxis_range' if nm == 'xaxis' else f'{nm}_range'
            self.assertEqual(upd.get(key), want_r, msg=key)
        print('  xaxis names:', names)
        print('  masters:', masters, 'relayout keys', len(upd))

    def test_q03_seven_row_shared_xaxes_master_xaxis7(self):
        print('\n[Q03] 七子图 shared_xaxes：主 x 为 xaxis7，relayout 须覆盖 xaxis…xaxis7（与 Notebook 多子图 K 线一致）')
        try:
            import plotly.graph_objects as go
            from plotly.subplots import make_subplots
        except ImportError:
            self.skipTest('plotly not installed')
        from qteasy.hp_visual_plotly import (
            _hp_plotly_layout_xaxis_names,
            _hp_plotly_master_xaxis_names,
            _hp_plotly_x_range_layout_updates,
        )

        fig = make_subplots(rows=7, cols=1, shared_xaxes=True)
        for r in range(1, 8):
            fig.add_trace(go.Scatter(x=list(range(10)), y=[r] * 10), row=r, col=1)
        names = _hp_plotly_layout_xaxis_names(fig.layout)
        masters = _hp_plotly_master_xaxis_names(fig.layout)
        self.assertEqual(len(names), 7)
        self.assertEqual(masters, ['xaxis7'])
        xr = [0.0, 87.0]
        upd = _hp_plotly_x_range_layout_updates(xr[0], xr[1], names)
        self.assertEqual(len(upd), 7)
        for nm in names:
            key = 'xaxis_range' if nm == 'xaxis' else f'{nm}_range'
            self.assertEqual(upd.get(key), xr, msg=key)
        print('  names', names, 'masters', masters)

    def test_q03_full_window_identity_stable(self):
        print('\n[Q03] 已显示全数据区间时平移归一不改变 range（稳定）')
        from qteasy.hp_visual_plotly import _hp_plotly_normalize_x_view_range

        n = 89
        max_x = float(n - 1)
        x0, x1, j0, j1 = _hp_plotly_normalize_x_view_range(n, 0.0, max_x)
        self.assertAlmostEqual(x0, 0.0, places=9)
        self.assertAlmostEqual(x1, max_x, places=9)
        self.assertEqual(j0, 0)
        self.assertEqual(j1, n - 1)
        print('  n=', n, '->', x0, x1)

    def test_q03_regression_dual_clamp_not_applied(self):
        print('\n[Q03] 回归：(-20,5)@n=100 若为双端独立 clamp 会得到 (0,5)，应保留平移语义 (0,25)')
        from qteasy.hp_visual_plotly import _hp_plotly_normalize_x_view_range

        x0, x1, _, _ = _hp_plotly_normalize_x_view_range(100, -20.0, 5.0)
        self.assertNotAlmostEqual(x1, 5.0, places=3)
        self.assertAlmostEqual(x1, 25.0, places=9)
        self.assertAlmostEqual(x1 - x0, 25.0, places=9)
        print('  got', x0, x1, 'not dual-clamp (0,5)')


class TestQ05SelectedCrosshairIndicator(unittest.TestCase):
    """Q05：选中态十字交叉线与 hover 日期优先契约。"""

    def test_q05_crosshair_meta_and_shapes_created(self):
        print('\n[Q05] 十字交叉线：meta/shapes + hover 日期优先')
        try:
            import plotly.graph_objects as go  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')

        from qteasy.hp_visual_plotly import (
            _hp_plotly_crosshair_mid_y_from_rec,
            _hp_plotly_crosshair_shapes,
            _PlotlyFigureWrapper,
            build_interactive_figure_from_specs,
        )

        hp = _make_hp(['open', 'high', 'low', 'close', 'vol'], n_shares=2, n_dates=12)
        specs_per_group, types_info, group_titles = _specs_per_group_stack_two_shares(hp)
        fig = build_interactive_figure_from_specs(
            specs_per_group,
            types_info,
            x_dates=list(hp.hdates),
            group_titles=group_titles,
        )
        meta = getattr(fig, '_hp_plotly_meta', {})
        self.assertTrue(meta.get('crosshair_enabled'), 'crosshair should be enabled when K-line exists')

        xref = meta.get('crosshair_xref_by_group', [])
        yref = meta.get('crosshair_yref_by_group', [])
        self.assertEqual(len(xref), meta.get('n_groups', 0))
        self.assertEqual(len(yref), meta.get('n_groups', 0))
        self.assertIn('crosshair_yaxis_layout_key_by_group', meta)

        shapes = list(fig.layout.shapes) if fig.layout.shapes else []
        self.assertGreaterEqual(len(shapes), 2, 'initial selected crosshair should create at least 2 shapes')
        self.assertEqual(shapes[0].type, 'line')
        print('  shapes:', len(shapes), 'first.type:', shapes[0].type)

        mid = _hp_plotly_crosshair_mid_y_from_rec({'open': 1.0, 'close': 3.0})
        self.assertAlmostEqual(mid, 2.0)
        print('  mid_y(open=1,close=3)->', mid)

        sh = _hp_plotly_crosshair_shapes(
            xref='x',
            yref='y',
            mid_x=5.0,
            x0=0.0,
            x1=9.0,
            y0=1.0,
            y1=10.0,
            mid_y=2.5,
            dashed=False,
        )
        self.assertEqual(len(sh), 2)
        self.assertEqual(sh[0]['x0'], sh[0]['x1'])

        # 不再实现鼠标移动 label trace；hover 通过 date-first template 统一
        label_traces = [
            tr for tr in fig.data
            if getattr(tr, 'name', '') and str(getattr(tr, 'name', '')).startswith('HP_CROSSHAIR_LABEL_')
        ]
        self.assertEqual(len(label_traces), 0)
        print('  label_traces removed:', len(label_traces))

        self.assertEqual(fig.layout.hovermode, 'closest')
        first_trace = fig.data[0]
        self.assertIn('%{text}', str(getattr(first_trace, 'hovertemplate', '') or ''))
        hl = getattr(first_trace, 'hoverlabel', None)
        self.assertIsNotNone(hl)
        self.assertIn('rgba(255,255,255,0.75)', str(getattr(hl, 'bgcolor', '') or ''))
        print('  hovermode:', fig.layout.hovermode, 'hovertemplate has %{text}:', '%{text}' in str(first_trace.hovertemplate))

        # 副图柱状图（volume/MACD bar）不显示日期标签（hovertemplate 不含 %{text}）
        bar_templates = []
        for tr in fig.data:
            if str(getattr(tr, 'type', '')).lower() == 'bar':
                bar_templates.append(str(getattr(tr, 'hovertemplate', '') or ''))
        self.assertGreater(len(bar_templates), 0)
        self.assertTrue(all('%{text}' not in t for t in bar_templates))
        print('  bar templates without %{text}:', len(bar_templates))

        wrapper = _PlotlyFigureWrapper(fig)
        html_str = wrapper._repr_html_()
        self.assertIn('_hp_crosshair_selected', html_str)
        self.assertIn('crosshair_xref_by_group', html_str)
        self.assertIn('%{text}', html_str)
        self.assertNotIn('showspikes', html_str)
        print('  html contains date-first hover template, no showspikes')


class TestHistoryPanelPlotP1Parity(unittest.TestCase):
    """P1：静态/交互表头、字体转译、图例 inset、蜡烛转译（双模对齐）。"""

    def test_p1a_static_ohlc_vol_header_last_bar(self):
        print('\n[T-P1a-1] 静态 OHLC+vol：顶栏 axes + 末根 close 出现在摘要中')
        hp = _make_hp(['open', 'high', 'low', 'close', 'vol'], n_dates=8, n_shares=1)
        fig = hp.plot()
        self.assertEqual(len(fig.axes), 4)
        hdr = fig.axes[0]
        self.assertTrue(getattr(hdr, '_hp_ohlc_header', False))
        ci = hp.htypes.index('close')
        last_close = float(hp.values[0, -1, ci])
        blob = ''.join(t.get_text() for t in hdr.texts)
        self.assertIn(f'{last_close:.2f}', blob)
        print('  header texts contain last close:', last_close)

    def test_p1a_no_kline_no_header_static_and_interactive_meta(self):
        print('\n[T-P1a-2] 无 K 线：静态无顶栏；交互不启用 OHLC 表头')
        hp = _make_hp(['close'], n_shares=1)
        fig = hp.plot()
        self.assertEqual(len(fig.axes), 1)
        self.assertFalse(getattr(fig.axes[0], '_hp_ohlc_header', False))
        try:
            fig2 = hp.plot(interactive=True)
        except RuntimeError as e:
            if 'plotly' in str(e).lower():
                self.skipTest('plotly not installed')
            raise
        base = fig2.figure if hasattr(fig2, 'figure') else fig2
        meta = getattr(base, '_hp_plotly_meta', {})
        self.assertFalse(meta.get('show_ohlc_header', True))
        self.assertFalse(bool(meta.get('initial_header_html')))
        print('  show_ohlc_header:', meta.get('show_ohlc_header'))

    def test_p1b_font_resolve_plotly_tick_vs_mpl(self):
        print('\n[T-P1b-1] 字体转译：plotly 轴刻度 = base+1；matplotlib 轴刻度 = max(7, base-1)')
        from qteasy.hp_visual_render import _get_theme
        from qteasy.hp_visual_theme_adapt import resolve_font_size

        theme = _get_theme()
        base = int(theme['font_size'])
        mpl_t = resolve_font_size('matplotlib', 'axis_tick', theme)
        ply_t = resolve_font_size('plotly', 'axis_tick', theme)
        self.assertEqual(ply_t, base + 1)
        self.assertEqual(mpl_t, max(7, base - 1))
        print('  mpl axis_tick:', mpl_t, 'plotly axis_tick:', ply_t)

    def test_p1c_plotly_legend_paper_inset(self):
        print('\n[T-P1c-1] 交互 K 线+MA：图例默认关闭（避免遮挡图表）')
        htypes = ['open', 'high', 'low', 'close', 'sma_20']
        hp = _make_hp(htypes, n_dates=15, n_shares=1)
        try:
            fig = hp.plot(interactive=True)
        except RuntimeError as e:
            if 'plotly' in str(e).lower():
                self.skipTest('plotly not installed')
            raise
        base = fig.figure if hasattr(fig, 'figure') else fig
        self.assertFalse(bool(getattr(base.layout, 'showlegend', True)))
        print('  showlegend:', base.layout.showlegend)

    def test_p1d_candle_style_adapt_and_trace(self):
        print('\n[T-P1d-1] 蜡烛颜色转译稳定且交互图含 Candlestick 样式')
        from qteasy.hp_visual_render import _get_theme
        from qteasy.hp_visual_theme_adapt import resolve_candle_style_plotly

        theme = _get_theme()
        c1 = resolve_candle_style_plotly(theme)
        c2 = resolve_candle_style_plotly(theme)
        self.assertEqual(c1, c2)
        self.assertIn('increasing_line_color', c1)
        w0 = float(theme.get('candle_plotly_whiskerwidth', 0.18))
        self.assertAlmostEqual(c1['whiskerwidth'], w0)
        hp = _make_hp(['open', 'high', 'low', 'close'])
        try:
            fig = hp.plot(interactive=True)
        except RuntimeError as e:
            if 'plotly' in str(e).lower():
                self.skipTest('plotly not installed')
            raise
        base = fig.figure if hasattr(fig, 'figure') else fig
        candle = next((tr for tr in base.data if getattr(tr, 'type', '') == 'candlestick'), None)
        self.assertIsNotNone(candle)
        inc = getattr(candle, 'increasing', None)
        fill = getattr(inc, 'fillcolor', None) if inc is not None else None
        self.assertIsNotNone(fill)
        print('  whiskerwidth:', c1['whiskerwidth'], 'increasing.fillcolor:', fill)


class TestHpVisualLayoutSpec(unittest.TestCase):
    """HpVisualLayoutSpec：单组/多组 stack 共用布局，表头占整图垂直比例一致。"""

    def test_mpl_header_absolute_inches_stable_across_group_count(self):
        print('\n[layout] MPL：多组时表头绝对高度（英寸）与单组参考一致，不随 fig 变高而倍增')
        from qteasy.hp_visual_layout import compute_hp_visual_layout_spec
        from qteasy.hp_visual_render import _get_theme

        class _TI:
            def __init__(self, tid: str, imp: str) -> None:
                self.id = tid
                self.importance = imp

        def _header_abs_inches(sp) -> float:
            fh = sp['mpl_figsize'][1]
            ratios = sp['mpl_height_ratios']
            if not ratios or sp['row_off'] == 0:
                return 0.0
            return fh * ratios[0] / sum(ratios)

        theme = _get_theme()
        f = float(theme['hp_header_vertical_fraction'])
        h_floor = float(theme['hp_mpl_fig_height_floor'])
        h_base = float(theme['hp_mpl_fig_height_base'])
        h_int = float(theme['hp_mpl_fig_height_intercept'])
        fig_h_ref = max(h_floor, h_base * 1.0 + h_int)
        target_abs = fig_h_ref * f
        types_info = [_TI('kline', 'main'), _TI('volume', 'secondary')]

        spec1 = compute_hp_visual_layout_spec(
            1, 2, types_info, theme, row_off=1, show_ohlc_header=True,
        )
        spec5 = compute_hp_visual_layout_spec(
            5, 2, types_info, theme, row_off=1, show_ohlc_header=True,
        )
        a1 = _header_abs_inches(spec1)
        a5 = _header_abs_inches(spec5)
        self.assertAlmostEqual(a1, target_abs, places=5)
        self.assertAlmostEqual(a5, target_abs, places=5)
        frac1 = spec1['mpl_height_ratios'][0] / sum(spec1['mpl_height_ratios'])
        frac5 = spec5['mpl_height_ratios'][0] / sum(spec5['mpl_height_ratios'])
        self.assertLess(frac5, frac1)
        print('  header abs inches 1grp/5grp:', a1, a5, 'target:', target_abs, 'frac5<frac1:', frac5, frac1)

    def test_plotly_row_heights_match_chart_ratios(self):
        print('\n[layout] Plotly 行高列表与 chart_height_ratios 一致（无表头行）')
        from qteasy.hp_visual_layout import compute_hp_visual_layout_spec
        from qteasy.hp_visual_render import _get_theme

        class _TI:
            def __init__(self, tid: str, imp: str) -> None:
                self.id = tid
                self.importance = imp

        theme = _get_theme()
        types_info = [_TI('kline', 'main'), _TI('volume', 'secondary')]
        spec = compute_hp_visual_layout_spec(
            2, 2, types_info, theme, row_off=1, show_ohlc_header=True,
        )
        self.assertEqual(spec['plotly_row_heights'], spec['chart_height_ratios'])
        self.assertEqual(spec['plotly_n_subplot_rows'], len(spec['chart_height_ratios']))
        print('  plotly rows:', spec['plotly_n_subplot_rows'])

    def test_mpl_header_gap_increases_fig_height_and_pre_chart_rows(self):
        print('\n[layout] MPL：默认 8mm 间隔行，fig 高度 +D 英寸，mpl_pre_chart_rows==2')
        from qteasy.hp_visual_layout import compute_hp_visual_layout_spec
        from qteasy.hp_visual_render import _get_theme

        class _TI:
            def __init__(self, tid: str, imp: str) -> None:
                self.id = tid
                self.importance = imp

        theme = _get_theme()
        types_info = [_TI('kline', 'main'), _TI('volume', 'secondary')]
        spec = compute_hp_visual_layout_spec(
            1, 2, types_info, theme, row_off=1, show_ohlc_header=True,
        )
        d_exp = float(theme['hp_mpl_header_gap_below_mm']) / 25.4
        h0 = max(
            float(theme['hp_mpl_fig_height_floor']),
            float(theme['hp_mpl_fig_height_base']) * 1.0 + float(theme['hp_mpl_fig_height_intercept']),
        )
        self.assertEqual(spec['mpl_pre_chart_rows'], 2)
        self.assertAlmostEqual(spec['mpl_header_gap_below_inches'], d_exp, places=5)
        self.assertAlmostEqual(spec['mpl_figsize'][1], h0 + d_exp, places=5)
        self.assertEqual(len(spec['mpl_height_ratios']), 2 + len(spec['type_ratios']))
        print('  fig_h:', spec['mpl_figsize'][1], 'h0+d:', h0 + d_exp, 'pre_chart_rows:', spec['mpl_pre_chart_rows'])


class TestQ09OverlayMvp(unittest.TestCase):
    """Q09-MVP：overlay 两 share 同轴叠加 + 动态主次透明度与按 share 表头数据契约。"""

    def test_q09_static_overlay_two_share_same_axis(self):
        print('\n[Q09-MVP] 静态 overlay：同一主图轴叠加两套 K 线（artist 数明显增加）')
        hp = _make_hp(['open', 'high', 'low', 'close', 'vol'], n_shares=2, n_dates=18)
        fig = hp.plot(layout='overlay')
        self.assertIsNotNone(fig)
        self.assertGreaterEqual(len(fig.axes), 4)
        price_axes = [
            ax for ax in fig.axes
            if not getattr(ax, '_hp_ohlc_header', False) and not getattr(ax, '_hp_mpl_gap', False)
        ]
        self.assertGreaterEqual(len(price_axes), 2)
        price_ax = price_axes[0]
        price_ax_r = price_axes[1]
        print('  price axis line count:', len(price_ax.lines), 'containers:', len(price_ax.containers))
        self.assertGreaterEqual(len(price_ax.containers), 10)
        self.assertIsNotNone(price_ax_r.get_ylabel())
        self.assertIn('(R)', price_ax_r.get_ylabel())

    def test_q09_static_overlay_line_widths_match_interactive_constants(self):
        print('\n[Q09] 静态 overlay：主次线宽与 hp_visual_overlay_style 常量一致（含 MA）')
        from qteasy.hp_visual_overlay_style import (
            HP_OVERLAY_PRIMARY_SCATTER_LINE_WIDTH,
            HP_OVERLAY_SECONDARY_SCATTER_LINE_WIDTH,
        )

        hp = _make_hp(['open', 'high', 'low', 'close', 'vol', 'ma_5'], n_shares=2, n_dates=18)
        fig = hp.plot(layout='overlay')
        price_axes = [
            ax for ax in fig.axes
            if not getattr(ax, '_hp_ohlc_header', False) and not getattr(ax, '_hp_mpl_gap', False)
        ]
        self.assertGreaterEqual(len(price_axes), 2)
        ax_l, ax_r = price_axes[0], price_axes[1]
        lw_left = {float(ln.get_linewidth()) for ln in ax_l.lines}
        lw_right = {float(ln.get_linewidth()) for ln in ax_r.lines}
        self.assertIn(float(HP_OVERLAY_PRIMARY_SCATTER_LINE_WIDTH), lw_left)
        self.assertIn(float(HP_OVERLAY_SECONDARY_SCATTER_LINE_WIDTH), lw_right)
        print('  MA linewidths L/R:', lw_left, lw_right)

    def test_q09_plotly_overlay_has_two_candles_and_opacity_split(self):
        print('\n[Q09-MVP] 动态 overlay：同 row 两套 candlestick，含主次透明度与 group/share customdata')
        try:
            import plotly.graph_objects as go  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')
        from qteasy.hp_visual_plotly import (
            HP_OVERLAY_PRIMARY_OPACITY,
            HP_OVERLAY_PRIMARY_SCATTER_LINE_WIDTH,
            HP_OVERLAY_SECONDARY_OPACITY,
            HP_OVERLAY_SECONDARY_SCATTER_LINE_WIDTH,
            build_interactive_figure_from_specs,
        )

        hp = _make_hp(['open', 'high', 'low', 'close', 'vol', 'ma_5'], n_shares=2, n_dates=20)
        specs_per_group, types_info, group_titles = _specs_per_group_overlay_two_shares(hp)
        fig = build_interactive_figure_from_specs(
            specs_per_group,
            types_info,
            x_dates=list(hp.hdates),
            group_titles=group_titles,
        )
        candles = [tr for tr in fig.data if str(getattr(tr, 'type', '')).lower() == 'candlestick']
        self.assertEqual(len(candles), 2)
        op_set = {float(getattr(tr, 'opacity', 1.0) or 1.0) for tr in candles}
        self.assertIn(float(HP_OVERLAY_PRIMARY_OPACITY), op_set)
        self.assertIn(float(HP_OVERLAY_SECONDARY_OPACITY), op_set)
        wick_set = {float(tr.increasing.line.width) for tr in candles}
        self.assertEqual(len(wick_set), 2)
        scatters = [tr for tr in fig.data if str(getattr(tr, 'type', '')).lower() == 'scatter']
        self.assertGreaterEqual(len(scatters), 2)
        self.assertIn(float(HP_OVERLAY_PRIMARY_SCATTER_LINE_WIDTH), {float(tr.line.width) for tr in scatters})
        self.assertIn(float(HP_OVERLAY_SECONDARY_SCATTER_LINE_WIDTH), {float(tr.line.width) for tr in scatters})
        yaxis_set = {str(getattr(tr, 'yaxis', 'y')) for tr in candles}
        self.assertEqual(len(yaxis_set), 2)
        self.assertIn('[S0]', str(getattr(fig.layout.yaxis.title, 'text', '') or ''))
        self.assertIn('[S1]', str(getattr(fig.layout.yaxis2.title, 'text', '') or ''))
        cd0 = getattr(candles[0], 'customdata', None)
        self.assertIsNotNone(cd0)
        self.assertGreater(len(cd0), 0)
        self.assertEqual(len(cd0[0]), 2)
        meta = getattr(fig, '_hp_plotly_meta', {})
        self.assertIn('bar_data_by_share', meta)
        self.assertTrue(bool(meta.get('overlay_group_flags', [False])[0]))
        self.assertIn('overlay_pri_scatter_lw', meta)
        print('  candles:', len(candles), 'opacity set:', sorted(list(op_set)), 'wick widths:', wick_set)

    def test_q09_html_script_contains_share_click_switch(self):
        print('\n[Q09-MVP] HTML 脚本契约：点击后按 share 切换 header 与透明度主次')
        try:
            import plotly.graph_objects as go  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')
        from qteasy.hp_visual_plotly import _PlotlyFigureWrapper, build_interactive_figure_from_specs

        hp = _make_hp(['open', 'high', 'low', 'close', 'vol'], n_shares=2, n_dates=20)
        specs_per_group, types_info, group_titles = _specs_per_group_overlay_two_shares(hp)
        fig = build_interactive_figure_from_specs(
            specs_per_group,
            types_info,
            x_dates=list(hp.hdates),
            group_titles=group_titles,
        )
        html_str = _PlotlyFigureWrapper(fig)._repr_html_()
        self.assertIn('bar_data_by_share', html_str)
        self.assertIn('overlay_active_share_by_group', html_str)
        self.assertIn('overlay_plot_rows_by_group', html_str)
        self.assertIn('overlay_row_axis_map_by_group', html_str)
        self.assertIn('pt.customdata', html_str)
        self.assertIn('tr.opacity', html_str)
        self.assertIn('tr2.yaxis', html_str)
        self.assertIn('overlay_pri_scatter_lw', html_str)
        self.assertIn('tr.increasing.line.width', html_str)
        print('  html length:', len(html_str))


class TestQ09OverlayFull(unittest.TestCase):
    """Q09-Full：无 volume / 含 MACD / 纯 line 等边界下 overlay 仍成立；主次线宽契约。"""

    def test_q09_overlay_ohlc_only_two_candles(self):
        print('\n[Q09-Full] 仅 OHLC、无 volume：overlay 仍为两套 K 线')
        try:
            import plotly.graph_objects as go  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')
        from qteasy.hp_visual_plotly import build_interactive_figure_from_specs

        hp = _make_hp(['open', 'high', 'low', 'close'], n_shares=2, n_dates=16)
        specs_per_group, types_info, group_titles = _specs_per_group_overlay_two_shares(hp)
        fig = build_interactive_figure_from_specs(
            specs_per_group,
            types_info,
            x_dates=list(hp.hdates),
            group_titles=group_titles,
        )
        candles = [tr for tr in fig.data if str(getattr(tr, 'type', '')).lower() == 'candlestick']
        self.assertEqual(len(candles), 2)
        bars = [tr for tr in fig.data if str(getattr(tr, 'type', '')).lower() == 'bar']
        self.assertEqual(len(bars), 0)
        print('  traces:', len(fig.data), 'candles:', len(candles))

    def test_q09_overlay_kline_volume_macd_rows(self):
        print('\n[Q09-Full] K+成交量+MACD：MACD 行 Scatter 具备主次线宽')
        try:
            import plotly.graph_objects as go  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')
        from qteasy.hp_visual_plotly import (
            HP_OVERLAY_PRIMARY_SCATTER_LINE_WIDTH,
            HP_OVERLAY_SECONDARY_SCATTER_LINE_WIDTH,
            build_interactive_figure_from_specs,
        )

        ht = [
            'open', 'high', 'low', 'close', 'vol',
            'macd_demo', 'macd_signal_demo', 'macd_hist_demo',
        ]
        hp = _make_hp(ht, n_shares=2, n_dates=18)
        specs_per_group, types_info, group_titles = _specs_per_group_overlay_two_shares(hp)
        fig = build_interactive_figure_from_specs(
            specs_per_group,
            types_info,
            x_dates=list(hp.hdates),
            group_titles=group_titles,
        )
        macd_scatters = [
            tr for tr in fig.data
            if str(getattr(tr, 'type', '')).lower() == 'scatter'
            and 'macd' in str(getattr(tr, 'name', '') or '').lower()
        ]
        self.assertGreaterEqual(len(macd_scatters), 2)
        lws = {float(tr.line.width) for tr in macd_scatters}
        self.assertIn(float(HP_OVERLAY_PRIMARY_SCATTER_LINE_WIDTH), lws)
        self.assertIn(float(HP_OVERLAY_SECONDARY_SCATTER_LINE_WIDTH), lws)
        print('  macd scatter count:', len(macd_scatters), 'line widths:', lws)

    def test_q09_overlay_line_only_two_shares(self):
        print('\n[Q09-Full] 仅 close 折线、两 share：双 trace 双 y、主次线宽')
        try:
            import plotly.graph_objects as go  # noqa: F401
        except ImportError:
            self.skipTest('plotly not installed')
        from qteasy.hp_visual_plotly import (
            HP_OVERLAY_PRIMARY_SCATTER_LINE_WIDTH,
            HP_OVERLAY_SECONDARY_SCATTER_LINE_WIDTH,
            build_interactive_figure_from_specs,
        )

        hp = _make_hp(['close'], n_shares=2, n_dates=14)
        specs_per_group, types_info, group_titles = _specs_per_group_overlay_two_shares(hp)
        fig = build_interactive_figure_from_specs(
            specs_per_group,
            types_info,
            x_dates=list(hp.hdates),
            group_titles=group_titles,
        )
        sc = [tr for tr in fig.data if str(getattr(tr, 'type', '')).lower() == 'scatter']
        self.assertEqual(len(sc), 2)
        yaxis_set = {str(getattr(tr, 'yaxis', 'y')) for tr in sc}
        self.assertEqual(len(yaxis_set), 2)
        lws = {float(tr.line.width) for tr in sc}
        self.assertIn(float(HP_OVERLAY_PRIMARY_SCATTER_LINE_WIDTH), lws)
        self.assertIn(float(HP_OVERLAY_SECONDARY_SCATTER_LINE_WIDTH), lws)
        meta = getattr(fig, '_hp_plotly_meta', {})
        self.assertTrue(bool(meta.get('overlay_group_flags', [False])[0]))
        print('  scatter line widths:', lws)


class TestPlotlyFigureWidgetHeaderPaperY(unittest.TestCase):
    """FigureWidget 表头：paper y 随 layout 高度反解，距顶毫米稳定。"""

    def test_plotly_header_paper_y_formula_and_order(self):
        print('\n[plotly-header-y] 反解 y1>y2，且随 height 变化')
        from qteasy.hp_visual_plotly import _plotly_header_paper_y_from_layout
        from qteasy.hp_visual_render import _get_theme

        theme = dict(_get_theme())
        mt, mb = 120.0, 40.0
        y1_400, y2_400 = _plotly_header_paper_y_from_layout(400.0, mt, mb, theme)
        y1_900, y2_900 = _plotly_header_paper_y_from_layout(900.0, mt, mb, theme)
        self.assertGreater(y1_400, y2_400)
        self.assertGreater(y1_900, y2_900)
        h_plot_400 = 400.0 - mt - mb
        d1 = float(theme['plotly_header_line1_top_mm']) * 96.0 / 25.4
        exp_y1 = 1.0 + (mt - d1) / h_plot_400
        self.assertAlmostEqual(y1_400, min(max(exp_y1, 1.02), 1.42), places=5)
        self.assertNotAlmostEqual(y1_400, y1_900, places=3)
        print('  y1@400/900:', y1_400, y1_900, 'y2@400:', y2_400)

    def test_layout_height_margin_tb_defaults(self):
        print('\n[plotly-header-y] _layout_height_margin_tb 缺省 margin')
        from qteasy.hp_visual_plotly import _layout_height_margin_tb
        from qteasy.hp_visual_render import _get_theme

        theme = _get_theme()

        class _Lay:
            height = 500
            margin = None

        h, t, b = _layout_height_margin_tb(_Lay(), theme, has_ohlc_header=True)
        self.assertEqual(h, 500.0)
        self.assertEqual(t, float(theme['plotly_margin_top_with_header']))
        self.assertEqual(b, 40.0)
        print('  height', h, 'margin_t', t, 'margin_b', b)


if __name__ == '__main__':
    unittest.main()
